├── .gitignore ├── Wdvh ├── GRADIENTABS.png ├── TerminalDosis-Bold.ttf ├── TerminalDosis-ExtraBold.ttf ├── star_gradient_title_only.png ├── README.md ├── StarWarsTitleOnly.py ├── WhiteTextTitleOnly.py ├── WhiteTextTitleOnlyLogo.py ├── WhiteTextAbsolute.py ├── WhiteTextStandard.py ├── WhiteTextAbsoluteLogo.py └── WhiteTextStandardLogo.py ├── lyonza ├── GRADIENTABS.png ├── TerminalDosis-Bold.ttf ├── TerminalDosis-ExtraBold.ttf ├── README.md └── WhiteTextBroadcast.py ├── Beedman ├── leftgradient.png ├── README.md └── GradientLogoTitleCard.py ├── Yozora ├── ref │ ├── retro │ │ ├── retro.ttf │ │ ├── gradient_play.png │ │ └── gradient_rewind.png │ ├── slim │ │ ├── GRADIENT.png │ │ ├── Comfortaa-Regular.ttf │ │ ├── Comfortaa-SemiBold.ttf │ │ └── LICENSE.txt │ └── barebones │ │ ├── Montserrat-Bold.ttf │ │ └── Montserrat-SemiBold.ttf ├── README.md ├── RetroTitleCard.py ├── BarebonesTitleCard.py └── SlimTitleCard.py ├── azuravian ├── leftgradient.png ├── README.md └── TitleColorMatch.py ├── CollinHeist ├── blacklist │ └── Blacklisted.ttf ├── README.md └── BlacklistTitleCard.py ├── LICENSE ├── KHthe8th ├── README.md └── TintedFramePlusTitleCard.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /Wdvh/GRADIENTABS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Wdvh/GRADIENTABS.png -------------------------------------------------------------------------------- /lyonza/GRADIENTABS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/lyonza/GRADIENTABS.png -------------------------------------------------------------------------------- /Beedman/leftgradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Beedman/leftgradient.png -------------------------------------------------------------------------------- /Wdvh/TerminalDosis-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Wdvh/TerminalDosis-Bold.ttf -------------------------------------------------------------------------------- /Yozora/ref/retro/retro.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Yozora/ref/retro/retro.ttf -------------------------------------------------------------------------------- /Yozora/ref/slim/GRADIENT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Yozora/ref/slim/GRADIENT.png -------------------------------------------------------------------------------- /azuravian/leftgradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/azuravian/leftgradient.png -------------------------------------------------------------------------------- /lyonza/TerminalDosis-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/lyonza/TerminalDosis-Bold.ttf -------------------------------------------------------------------------------- /Wdvh/TerminalDosis-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Wdvh/TerminalDosis-ExtraBold.ttf -------------------------------------------------------------------------------- /Wdvh/star_gradient_title_only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Wdvh/star_gradient_title_only.png -------------------------------------------------------------------------------- /Yozora/ref/retro/gradient_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Yozora/ref/retro/gradient_play.png -------------------------------------------------------------------------------- /lyonza/TerminalDosis-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/lyonza/TerminalDosis-ExtraBold.ttf -------------------------------------------------------------------------------- /CollinHeist/blacklist/Blacklisted.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/CollinHeist/blacklist/Blacklisted.ttf -------------------------------------------------------------------------------- /Yozora/ref/retro/gradient_rewind.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Yozora/ref/retro/gradient_rewind.png -------------------------------------------------------------------------------- /Yozora/ref/slim/Comfortaa-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Yozora/ref/slim/Comfortaa-Regular.ttf -------------------------------------------------------------------------------- /Yozora/ref/slim/Comfortaa-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Yozora/ref/slim/Comfortaa-SemiBold.ttf -------------------------------------------------------------------------------- /Yozora/ref/barebones/Montserrat-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Yozora/ref/barebones/Montserrat-Bold.ttf -------------------------------------------------------------------------------- /Yozora/ref/barebones/Montserrat-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CollinHeist/TitleCardMaker-CardTypes/HEAD/Yozora/ref/barebones/Montserrat-SemiBold.ttf -------------------------------------------------------------------------------- /CollinHeist/README.md: -------------------------------------------------------------------------------- 1 | # `CollinHeist/BlacklistTitleCard` 2 | ## Description 3 | This is a card type I created for a Reddit user's request for the series `The Blacklist`. The general layout is a promintent title and then a "blacklist number" (episode text formatted as `NO. {number}`) beneath the title. The default font is also from the series `The Blacklist`. 4 | 5 | ## Example 6 | -------------------------------------------------------------------------------- /Beedman/README.md: -------------------------------------------------------------------------------- 1 | # GradientLogoTitleCard 2 | ## Description 3 | A modification of [StandardTitleCard](https://github.com/CollinHeist/TitleCardMaker/wiki/StandardTitleCard) to include the logo, with a left-heavy gradient to go with left-aligned text and logo. 4 | 5 | ## Specification 6 | The logo file is passed into this card the same way as the [LogoTitleCard](https://github.com/CollinHeist/TitleCardMaker/wiki/LogoTitleCard#specification), i.e.: 7 | 8 | ```yaml 9 | series: 10 | Peacemaker: 11 | year: 2022 12 | extras: 13 | logo: ./source/Peacemaker (2022)/logo.png 14 | ``` 15 | 16 | ## Example Cards 17 | 18 | 19 | ## Features 20 | - Logo and text 21 | - Different gradient 22 | - Left-aligned 23 | -------------------------------------------------------------------------------- /azuravian/README.md: -------------------------------------------------------------------------------- 1 | # TitleColorMatchTitleCard 2 | ## Description 3 | A modification of [GradientLogoTitleCard](https://github.com/CollinHeist/TitleCardMaker-CardTypes/tree/master/Beedman) to include the option to auto-select font color based on logo color. It will also automatically crop off extraneous transparent space from the logo. 4 | 5 | ## Specification 6 | The logo file is passed into this card the same way as the [LogoTitleCard](https://github.com/CollinHeist/TitleCardMaker/wiki/LogoTitleCard#specification), i.e.: 7 | 8 | ```yaml 9 | series: 10 | Peacemaker: 11 | year: 2022 12 | extras: 13 | logo: ./source/Peacemaker (2022)/logo.png 14 | ``` 15 | 16 | To turn on automatic matching of colors, add the following to your template config: 17 | ```yaml 18 | mytemplate: 19 | font: 20 | color: 'auto' 21 | ``` 22 | 23 | ## Example Cards 24 | 25 | 26 | ## Features 27 | - Logo and text 28 | - Gradient 29 | - Left-aligned 30 | - Auto-matching text to logo color 31 | -------------------------------------------------------------------------------- /lyonza/README.md: -------------------------------------------------------------------------------- 1 | # `lyonza/WhiteTextAbsolute` 2 | ## Description 3 | This is a modification of [Wvdh](https://github.com/Wdvh)'s [WhiteTextAbsolute](https://github.com/CollinHeist/TitleCardMaker-CardTypes/blob/master/Wdvh/README.md#whitetextstandard) title card. 4 | 5 | The WhiteTextAbsolute uses the different font, font sizes and the episode title is positioned lower than the [StandardTitleCard](https://github.com/CollinHeist/TitleCardMaker/wiki/StandardTitleCard). The WhiteTextAbsolute uses the same fonts and sizes but hides the seasons and has the episode count positioned in the left top corner, the episode titles are positioned even lower than the standardcard. 6 | 7 | This card uses the same positioning but has the option to have the season number included. 8 | 9 | ## Example 10 | 11 | 12 | ## Customization 13 | The gradient overlay can be omitted by specifying the `omit_gradient` extra as `true`, like so: 14 | ```yaml 15 | extras: 16 | omit_gradient: true 17 | ``` 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Collin Heist 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /KHthe8th/README.md: -------------------------------------------------------------------------------- 1 | # `KHthe8th/TintedFramePlusTitleCard` 2 | ## Description 3 | A combination of the [TintedFrameTitleCard](https://github.com/CollinHeist/TitleCardMaker/wiki/TintedFrameTitleCard) and the [StandardTitleCard](https://github.com/CollinHeist/TitleCardMaker/wiki/StandardTitleCard) for the best of both worlds. It has the same extras you can pass to tinted frame, and you can still modify the top/middle/bottom frame elements with extras but now it adds the episode title text above the bottom frame element (this is not modifiable). The top element will default to logo now, and title is no longer an option (as it is always shown above the bottom element). 4 | 5 | ## Example 6 | ![House (2004) - S01E02](https://github.com/khthe8th/TitleCardMaker-CardTypes/assets/5308389/d089a1b1-7458-4eaf-ad8d-59c7f332a7c1) 7 | 8 | ## Specification 9 | Image shown above has template: 10 | 11 | ```yaml 12 | templates: 13 | myTemplate: 14 | library: <> 15 | card_type: KHthe8th/TintedFramePlusTitleCard 16 | extras: 17 | logo: ./source/<> (<>)/logo.png 18 | episode_text_font_size: 1.3 19 | ``` 20 | It also has all optional valid extras listed on [TintedFrameTitleCard](https://github.com/CollinHeist/TitleCardMaker/wiki/TintedFrameTitleCard#valid-extras), as well as stroke_color listed on [StandardTitleCard](https://github.com/CollinHeist/TitleCardMaker/wiki/StandardTitleCard#custom-stroke-color) 21 | -------------------------------------------------------------------------------- /Wdvh/README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | These are modification of the [StandardTitleCard](https://github.com/CollinHeist/TitleCardMaker/wiki/StandardTitleCard). The WhiteTextStandard uses the different font, font sizes and the episode title is positioned lower than the StandardTitleCard. The WhiteTextAbsolute uses the same fonts and sizes but hides the seasons and has the episode count positioned in the left top corner, the episode titles are positioned even lower than the standardcard. 4 | 5 | ## White Text Cards 6 | 7 | ### White Text Standard 8 | 9 | 10 | 11 | ### White Text Absolute 12 | 13 | 14 | 15 | ### White Text Title Only 16 | 17 | 18 | 19 | ## Features 20 | 21 | - Uses the TerminalDosis-Bold font for Titles and Sequel Neue for the season and episode numbers. 22 | - Has White as font color with a dark blue stroke. 23 | 24 | ## Star Wars 25 | 26 | A variation of the standard Star Wars Card for Shows Like Obi-Wan Kenobi that use Part/Chapter and similar as episode titles. 27 | 28 | ### Star Wars Title Only 29 | 30 | 31 | 32 | ## Features 33 | 34 | - uses a different position and gradient for the title. 35 | 36 | ## White Text Logo Cards 37 | ### White Text Standard Logo 38 | 39 | 40 | 41 | ### White Text Absolute Logo 42 | 43 | 44 | 45 | ### White Text Title Only Logo 46 | 47 | 48 | 49 | ## Features 50 | 51 | - All 3 cards use only the show logo and a background color instead of the usual still from the episode. 52 | - Logo and color must be defined in the series.yml. 53 | - Can be used to make a non spoiler version for unwatched episodes. 54 | -------------------------------------------------------------------------------- /Yozora/README.md: -------------------------------------------------------------------------------- 1 | # BarebonesTitleCard 2 | ## Description 3 | This is a minimalistic custom CardType which emulates the main features of the [StarWarsTitleCard](https://github.com/CollinHeist/TitleCardMaker/wiki/StarWarsTitleCard). The main difference made to this CardType is the rework of the card to make it more universal in terms of style and design (such as changing the star-themed gradient) 4 | 5 | ## Example Cards 6 | 7 | 8 | ## Features 9 | The following outlines the differences from the StandardTitleCard. 10 | 11 | - Default font switched to Montserrat 12 | - Episode and Title color change now supported 13 | - Removed Gradient 14 | 15 | All other features are as per the StarWarsTitleCard. 16 | 17 | # RetroTitleCard 18 | ## Description 19 | This is a custom CardType which is inspired by old-school VHS tapes and camcorders. The main difference made to this CardType is the rework of the card to make it more universal in terms of style and design (such as changing the star-themed gradient) 20 | 21 | ## Specification 22 | By default, watched cards (as determined by your Plex library) will have `REWIND` on them, and be in black and white; while unwatched cards will have `PLAY` and be in full color. These can be overwritten via series [extras](https://github.com/CollinHeist/TitleCardMaker/wiki/Series-YAML-Files#extras) like so: 23 | 24 | ```yaml 25 | series: 26 | Breaking Bad (2008): 27 | card_type: Yozora/RetroTitleCard 28 | extras: 29 | override_bw: bw 30 | override_style: play 31 | ``` 32 | 33 | This would make all cards black and white with `PLAY` on them. `override_bw` can be either `bw` or `color`; while `override_style` can be either `play` or `rewind`. 34 | 35 | ## Example Cards 36 | 37 | 38 | ## Example Watched Card 39 | The below image showcases the greyscale option which is applied to watched episodes: 40 | 41 | 42 | ## Features 43 | The following outlines the main features of this TitleCard 44 | 45 | - Inspired by VHS and Camcorders 46 | - Greyscale and "Play" text changed to "Rewind" for already watched episodes 47 | 48 | All other features are as per the StandardTitleCard - including multi-line support, blur, and other options. 49 | 50 | # SlimTitleCard 51 | ## Description 52 | This is a minimalistic custom CardType which emulates the main features of the [StandardTitleCard](https://github.com/CollinHeist/TitleCardMaker/wiki/StandardTitleCard). The main difference made to this CardType is that the text is moved vertically down to allow the image to be showcased further.. 53 | 54 | ## Example Cards 55 | 56 | 57 | ## Features 58 | The following outlines the differences from the StandardTitleCard. 59 | 60 | - Default font switched to Axiforma 61 | - Episode and Season count moved vertically down 62 | - Title moved vertically down 63 | 64 | All other features are as per the StandardTitleCard - including multi-line support, blur, and other options. 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Card Types for the `TitleCardMaker` 2 | This repository contains user-created card types for use in the [TitleCardMaker](https://github.com/CollinHeist/TitleCardMaker). 3 | 4 | # Contributing 5 | In order to contribute your own custom Card Type to this repository, follow these steps: 6 | 7 | 1. [Create a fork](https://github.com/CollinHeist/TitleCardMaker-CardTypes/fork) of this repo 8 | 2. Create a folder with your Github username (i.e. `/CollinHeist/`) 9 | 3. Inside that folder, create a `README.md` file with at least one example and a description of your card (as well as any nuances / features) 10 | 4. Add your custom Card Type Python class (that follows the specifications outlined [here](https://github.com/CollinHeist/TitleCardMaker/wiki/Custom-Card-Types#creating-a-custom-card-type)) to your username folder 11 | * Be sure to [read the wiki](https://github.com/CollinHeist/TitleCardMaker-CardTypes/wiki) on the specific syntax required for your CardType to work as a remote asset. 12 | * It can be helpful to look at my example (`CollinHeist/BetterStandardTitleCard`), or an existing card type, for syntax help 13 | 5. Edit the below [table](https://github.com/CollinHeist/TitleCardMaker-CardTypes#available-card-types) with your username and an example of your card 14 | 6. Submit a pull request to this repository 15 | 16 | > NOTE: By nature of how the Maker pulls in these files, all pull requests and Card Types will be thoroughly vetted for security implications. Please help me out in this process by documenting your code, and avoiding any unnecessary obfuscations. 17 | 18 | # Available Card Types 19 | | Creator | `card_type` Specification | Example | 20 | | :---: | :---: | :--- | 21 | | Wdvh | `Wdvh/WhiteTextStandard` | | 22 | | Wdvh | `Wdvh/WhiteTextAbsolute` | | 23 | | Wdvh | `Wdvh/WhiteTextTitleOnly` | | 24 | | lyonza | `lyonza/WhiteTextBroadcast` | | 25 | | Wdvh | `Wdvh/StarWarsTitleOnly` | | 26 | | Beedman | `Beedman/GradientLogoTitleCard` | | 27 | | Yozora | `Yozora/SlimTitleCard` | | 28 | | Yozora | `Yozora/BarebonesTitleCard` | | 29 | | Yozora | `Yozora/RetroTitleCard` | | 30 | | Wdvh | `Wdvh/WhiteTextAbsoluteLogo` | | 31 | | Wdvh | `Wdvh/WhiteTextStandardLogo` | | 32 | | Wdvh | `Wdvh/WhiteTextTitleOnlyLogo` | | 33 | | azuravian | `azuravian/TitleColorMatch` | | 34 | | CollinHeist | `CollinHeist/BlacklistTitleCard` | | 35 | | KHthe8th | `KHthe8th/TintedFramePlus` | | 36 | 37 | # Using a Custom Card Type 38 | The [available card types](#available-card-types) can all be specified within the Maker by adding the following: 39 | 40 | ```yaml 41 | card_type: {USER/CARDTYPE} 42 | ``` 43 | 44 | To a specific series, template, or library. For example, to create The Blacklist in my example card type, it might look like: 45 | 46 | ```yaml 47 | series: 48 | The Blacklist (2013): 49 | card_type: CollinHeist/BlacklistTitleCard 50 | ``` 51 | -------------------------------------------------------------------------------- /Wdvh/StarWarsTitleOnly.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | from modules.BaseCardType import BaseCardType 5 | from modules.Debug import log 6 | from modules.RemoteFile import RemoteFile 7 | 8 | class StarWarsTitleOnly(BaseCardType): 9 | """ 10 | This class describes a type of ImageMaker that produces title cards in the 11 | theme of Star Wars cards as designed by reddit user /u/Olivier_286. These 12 | cards are not as customizable as the standard template. 13 | """ 14 | 15 | """Directory where all reference files used by this card are stored""" 16 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' / 'star_wars' 17 | 18 | """Characteristics for title splitting by this class""" 19 | TITLE_CHARACTERISTICS = { 20 | 'max_line_width': 16, # Character count to begin splitting titles 21 | 'max_line_count': 5, # Maximum number of lines a title can take up 22 | 'top_heavy': True, # This class uses top heavy titling 23 | } 24 | 25 | """How to name archive directories for this type of card""" 26 | ARCHIVE_NAME = 'Star Wars Title Only Style' 27 | 28 | """Path to the font to use for the episode title""" 29 | TITLE_FONT = str((REF_DIRECTORY/'Monstice-Base.ttf').resolve()) 30 | 31 | """Color to use for the episode title""" 32 | TITLE_COLOR = '#DAC960' 33 | 34 | """Default episode text format string""" 35 | EPISODE_TEXT_FORMAT = ' ' 36 | 37 | """Standard font replacements for the title font""" 38 | FONT_REPLACEMENTS = {'Ō': 'O', 'ō': 'o'} 39 | 40 | """Whether this class uses season titles for the purpose of archives""" 41 | USES_SEASON_TITLE = False 42 | 43 | """Path to the reference star image to overlay on all source images""" 44 | __STAR_GRADIENT_IMAGE = RemoteFile('Wdvh', 'star_gradient_title_only.png') 45 | 46 | __slots__ = ('source_file', 'output_file', 'title') 47 | 48 | 49 | def __init__(self, *, 50 | source_file: Path, 51 | card_file: Path, 52 | title_text: str, 53 | blur: bool = False, 54 | grayscale: bool = False, 55 | **unused) -> None: 56 | """ 57 | Initialize this CardType object. 58 | """ 59 | 60 | # Initialize the parent class - this sets up an ImageMagickInterface 61 | super().__init__(blur, grayscale) 62 | 63 | # Store source and output file 64 | self.source_file = source_file 65 | self.output_file = card_file 66 | 67 | # Store episode title 68 | self.title = self.image_magick.escape_chars(title_text.upper()) 69 | 70 | 71 | @staticmethod 72 | def is_custom_font(font: 'Font') -> bool: 73 | """ 74 | Determines whether the given font characteristics constitute a 75 | default or custom font. 76 | 77 | Args: 78 | font: The Font being evaluated. 79 | 80 | Returns: 81 | False, as custom fonts are not used. 82 | """ 83 | 84 | return False 85 | 86 | 87 | @staticmethod 88 | def is_custom_season_titles( 89 | custom_episode_map: bool, episode_text_format: str) -> bool: 90 | """ 91 | Determines whether the given attributes constitute custom or 92 | generic season titles. 93 | 94 | Args: 95 | custom_episode_map: Whether the EpisodeMap was customized. 96 | episode_text_format: The episode text format in use. 97 | 98 | Returns: 99 | False. Custom season titles are not used. 100 | """ 101 | 102 | return False 103 | 104 | 105 | def create(self) -> None: 106 | """ 107 | Make the necessary ImageMagick and system calls to create this 108 | object's defined title card. 109 | """ 110 | 111 | command = ' '.join([ 112 | f'convert "{self.source_file.resolve()}"', 113 | # Resize input and apply any style modifiers 114 | *self.resize_and_style, 115 | # Overlay the star gradient 116 | f'"{self.__STAR_GRADIENT_IMAGE.resolve()}"', 117 | f'-composite', 118 | # Add title text 119 | f'-font "{self.TITLE_FONT}"', 120 | f'-gravity northwest', 121 | f'-pointsize 124', 122 | f'-kerning 0.5', 123 | f'-interline-spacing 20', 124 | f'-fill "{self.TITLE_COLOR}"', 125 | f'-annotate +320+1529 "{self.title}"', 126 | # Resize and write output 127 | *self.resize_output, 128 | f'"{self.output_file.resolve()}"', 129 | ]) 130 | 131 | self.image_magick.run(command) -------------------------------------------------------------------------------- /CollinHeist/BlacklistTitleCard.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from modules.BaseCardType import BaseCardType, ImageMagickCommands 4 | from modules.Debug import log 5 | from modules.RemoteFile import RemoteFile 6 | 7 | class BlacklistTitleCard(BaseCardType): 8 | """ 9 | 10 | """ 11 | 12 | """Characteristics for title splitting by this class""" 13 | TITLE_CHARACTERISTICS = { 14 | 'max_line_width': 15, # Character count to begin splitting titles 15 | 'max_line_count': 4, # Maximum number of lines a title can take up 16 | 'top_heavy': True, # This class uses bottom heavy titling 17 | } 18 | 19 | """How to name archive directories for this type of card""" 20 | ARCHIVE_NAME = 'Blacklist Style' 21 | 22 | """Characteristics of the default title font""" 23 | TITLE_FONT = str(RemoteFile('CollinHeist', 'blacklist/Blacklisted.ttf')) 24 | TITLE_COLOR = 'rgb(177,21,10)' 25 | DEFAULT_FONT_CASE = 'upper' 26 | FONT_REPLACEMENTS = {} 27 | 28 | """Characteristics of the episode text""" 29 | EPISODE_TEXT_FORMAT = 'NO. {episode_number}' 30 | 31 | """Whether this class uses season titles for the purpose of archives""" 32 | USES_SEASON_TITLE = False 33 | 34 | __slots__ = ( 35 | 'source_file', 'output_file', 'title_text', 'episode_text', 36 | 'line_count', 'font_color', 'font_file', 'font_size', 37 | 'font_interline_spacing', 38 | ) 39 | 40 | def __init__(self, 41 | source_file: Path, 42 | card_file: Path, 43 | title_text: str, 44 | episode_text: str, 45 | font_file: str = TITLE_FONT, 46 | font_color: str = TITLE_COLOR, 47 | font_interline_spacing: int = 0, 48 | font_size: float = 1.0, 49 | blur: bool = False, 50 | grayscale: bool = False, 51 | **unused) -> None: 52 | """ 53 | Construct a new instance of this Card. 54 | """ 55 | 56 | # Initialize the parent class - this sets up an ImageMagickInterface 57 | super().__init__(blur, grayscale) 58 | 59 | # Store source and output file 60 | self.source_file = source_file 61 | self.output_file = card_file 62 | 63 | # Escape title, season, and episode text 64 | self.title_text = self.image_magick.escape_chars(title_text) 65 | self.episode_text = self.image_magick.escape_chars(episode_text.upper()) 66 | self.line_count = len(title_text.split('\n')) 67 | 68 | # Font customizations 69 | self.font_color = font_color 70 | self.font_file = font_file 71 | self.font_interline_spacing = font_interline_spacing 72 | self.font_size = font_size 73 | 74 | 75 | @staticmethod 76 | def is_custom_font(font: 'Font') -> bool: 77 | """ 78 | Determines whether the given arguments represent a custom font 79 | for this card. 80 | 81 | Args: 82 | font: The Font being evaluated. 83 | 84 | Returns: 85 | True if a custom font is indicated, False otherwise. 86 | """ 87 | 88 | return ((font.color != BlacklistTitleCard.TITLE_COLOR) 89 | or (font.file != BlacklistTitleCard.TITLE_FONT) 90 | or (font.interline_spacing != 0) 91 | or (font.kerning != 1.0) 92 | or (font.size != 1.0) 93 | or (font.vertical_shift != 0) 94 | ) 95 | 96 | 97 | @staticmethod 98 | def is_custom_season_titles( 99 | custom_episode_map: bool, episode_text_format: str) -> bool: 100 | """ 101 | Determines whether the given attributes constitute custom or 102 | generic season titles. 103 | 104 | Args: 105 | custom_episode_map: Whether the EpisodeMap was customized. 106 | episode_text_format: The episode text format in use. 107 | 108 | Returns: 109 | False, as custom season titles are not used. 110 | """ 111 | 112 | return False 113 | 114 | 115 | def create(self) -> None: 116 | """ 117 | Make the necessary ImageMagick and system calls to create this 118 | object's defined title card. 119 | """ 120 | 121 | episode_text_offset = 150 + (250 * self.line_count) 122 | font_size = 230 * self.font_size 123 | interline_spacing = 30 + self.font_interline_spacing 124 | 125 | command = ' '.join([ 126 | f'convert "{self.source_file.resolve()}"', 127 | # Resize and apply styles 128 | *self.resize_and_style, 129 | # Add title text 130 | f'-font "{self.font_file}"', 131 | f'-fill "{self.font_color}"', 132 | f'-interline-spacing {interline_spacing}', 133 | f'-pointsize {font_size}', 134 | f'-gravity northwest', 135 | f'-annotate +150+150 "{self.title_text}"', 136 | # Add episode text 137 | f'-pointsize 120', 138 | f'-annotate +150+{episode_text_offset} "{self.episode_text}"', 139 | f'"{self.output_file.resolve()}"', 140 | ]) 141 | 142 | self.image_magick.run(command) -------------------------------------------------------------------------------- /Wdvh/WhiteTextTitleOnly.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from modules.BaseCardType import BaseCardType 4 | from modules.Debug import log 5 | from modules.RemoteFile import RemoteFile 6 | 7 | class WhiteTextTitleOnly(BaseCardType): 8 | """ 9 | This class describes Wdvh's title only title CardType. 10 | """ 11 | 12 | """Directory where all reference files used by this card are stored""" 13 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' 14 | 15 | """Characteristics for title splitting by this class""" 16 | TITLE_CHARACTERISTICS = { 17 | 'max_line_width': 32, # Character count to begin splitting titles 18 | 'max_line_count': 3, # Maximum number of lines a title can take up 19 | 'top_heavy': False, # This class uses bottom heavy titling 20 | } 21 | 22 | """Default font and text color for episode title text""" 23 | TITLE_FONT = str(RemoteFile('Wdvh', 'TerminalDosis-Bold.ttf')) 24 | TITLE_COLOR = '#FFFFFF' 25 | 26 | """Default characters to replace in the generic font""" 27 | FONT_REPLACEMENTS = {} 28 | 29 | """Whether this CardType uses season titles for archival purposes""" 30 | USES_SEASON_TITLE = False 31 | 32 | """Standard class has standard archive name""" 33 | ARCHIVE_NAME = 'White Text Title Only Style' 34 | 35 | """Source path for the gradient image overlayed over all title cards""" 36 | __GRADIENT_IMAGE = REF_DIRECTORY / 'GRADIENT.png' 37 | 38 | """Paths to intermediate files that are deleted after the card is created""" 39 | __SOURCE_WITH_GRADIENT = BaseCardType.TEMP_DIR / 'source_gradient.png' 40 | 41 | __slots__ = ( 42 | 'source_file', 'output_file', 'title', 'font', 'font_size', 43 | 'title_color', 'vertical_shift', 'interline_spacing', 'kerning', 44 | 'stroke_width' 45 | ) 46 | 47 | 48 | def __init__(self, *, 49 | source_file: Path, 50 | card_file: Path, 51 | title_text: str, 52 | font_color: str = TITLE_COLOR, 53 | font_file: str = TITLE_FONT, 54 | font_kerning: float = 1.0, 55 | font_interline_spacing: int = 0, 56 | font_size: float = 1.0, 57 | font_stroke_width: float = 1.0, 58 | font_vertical_shift: int = 0, 59 | blur: bool = False, 60 | grayscale: bool = False, 61 | **unused) -> None: 62 | """ 63 | Initialize this CardType object. 64 | """ 65 | 66 | # Initialize the parent class - this sets up an ImageMagickInterface 67 | super().__init__(blur, grayscale) 68 | 69 | self.source_file = source_file 70 | self.output_file = card_file 71 | 72 | # Ensure characters that need to be escaped are 73 | self.title = self.image_magick.escape_chars(title_text) 74 | 75 | self.font = font_file 76 | self.font_size = font_size 77 | self.title_color = font_color 78 | self.vertical_shift = font_vertical_shift 79 | self.interline_spacing = font_interline_spacing 80 | self.kerning = font_kerning 81 | self.stroke_width = font_stroke_width 82 | 83 | 84 | def __title_text_global_effects(self) -> list[str]: 85 | """ 86 | ImageMagick commands to implement the title text's global effects. 87 | Specifically the the font, kerning, fontsize, and center gravity. 88 | 89 | Returns: 90 | List of ImageMagick commands. 91 | """ 92 | 93 | font_size = 180 * self.font_size 94 | interline_spacing = -17 + self.interline_spacing 95 | kerning = -1.25 * self.kerning 96 | 97 | return [ 98 | f'-font "{self.font}"', 99 | f'-kerning {kerning}', 100 | f'-interword-spacing 50', 101 | f'-interline-spacing {interline_spacing}', 102 | f'-pointsize {font_size}', 103 | f'-gravity south', 104 | ] 105 | 106 | 107 | def __title_text_black_stroke(self) -> list[str]: 108 | """ 109 | ImageMagick commands to implement the title text's black stroke. 110 | 111 | Returns: 112 | List of ImageMagick commands. 113 | """ 114 | 115 | stroke_width = 4.0 * self.stroke_width 116 | 117 | return [ 118 | f'-fill white', 119 | f'-stroke "#062A40"', 120 | f'-strokewidth {stroke_width}', 121 | ] 122 | 123 | 124 | def _add_gradient(self) -> Path: 125 | """ 126 | Add the static gradient to this object's source image. 127 | 128 | Returns: 129 | Path to the created image. 130 | """ 131 | 132 | command = ' '.join([ 133 | f'convert "{self.source_file.resolve()}"', 134 | *self.resize_and_style, 135 | f'"{self.__GRADIENT_IMAGE.resolve()}"', 136 | f'-background None', 137 | f'-layers Flatten', 138 | f'"{self.__SOURCE_WITH_GRADIENT.resolve()}"', 139 | ]) 140 | 141 | self.image_magick.run(command) 142 | 143 | return self.__SOURCE_WITH_GRADIENT 144 | 145 | 146 | def _add_title_text(self, gradient_image: Path) -> Path: 147 | """ 148 | Adds episode title text to the provide image. 149 | 150 | :param gradient_image: The image with gradient added. 151 | 152 | :returns: Path to the created image that has a gradient and the title 153 | text added. 154 | """ 155 | 156 | vertical_shift = 50 + self.vertical_shift 157 | 158 | command = ' '.join([ 159 | f'convert "{gradient_image.resolve()}"', 160 | *self.__title_text_global_effects(), 161 | *self.__title_text_black_stroke(), 162 | f'-annotate +0+{vertical_shift} "{self.title}"', 163 | f'-fill "{self.title_color}"', 164 | f'-annotate +0+{vertical_shift} "{self.title}"', 165 | *self.resize_output, 166 | f'"{self.output_file.resolve()}"', 167 | ]) 168 | 169 | self.image_magick.run(command) 170 | 171 | return self.output_file 172 | 173 | 174 | @staticmethod 175 | def is_custom_font(font: 'Font') -> bool: 176 | """ 177 | Determines whether the given font characteristics constitute a 178 | default or custom font. 179 | 180 | Args: 181 | font: The Font being evaluated. 182 | 183 | Returns: 184 | True if a custom font is indicated, False otherwise. 185 | """ 186 | 187 | return ((font.file != WhiteTextTitleOnly.TITLE_FONT) 188 | or (font.size != 1.0) 189 | or (font.color != WhiteTextTitleOnly.TITLE_COLOR) 190 | or (font.vertical_shift != 0) 191 | or (font.interline_spacing != 0) 192 | or (font.kerning != 1.0) 193 | or (font.stroke_width != 1.0)) 194 | 195 | 196 | @staticmethod 197 | def is_custom_season_titles( 198 | custom_episode_map: bool, episode_text_format: str) -> bool: 199 | """ 200 | Determines whether the given attributes constitute custom or 201 | generic season titles. 202 | 203 | Args: 204 | custom_episode_map: Whether the EpisodeMap was customized. 205 | episode_text_format: The episode text format in use. 206 | 207 | Returns: 208 | False, as custom season titles are not used. 209 | """ 210 | 211 | return False 212 | 213 | 214 | def create(self) -> None: 215 | """ 216 | Make the necessary ImageMagick and system calls to create this object's 217 | defined title card. 218 | """ 219 | 220 | # Add the gradient to the source image (always) 221 | gradient_image = self._add_gradient() 222 | 223 | # Add either one or two lines of episode text 224 | self._add_title_text(gradient_image) 225 | 226 | # Delete all intermediate images 227 | self.image_magick.delete_intermediate_images(gradient_image) -------------------------------------------------------------------------------- /lyonza/WhiteTextBroadcast.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from modules.BaseCardType import BaseCardType, ImageMagickCommands 4 | from modules.Debug import log 5 | from modules.RemoteFile import RemoteFile 6 | 7 | class WhiteTextBroadcast(BaseCardType): 8 | """ 9 | This class describes lyonza's CardType based on Wvdh's 10 | "WhiteTextBroadcast" card to show SxxExx format instead of absolute 11 | numbering 12 | """ 13 | 14 | """Directory where all reference files used by this card are stored""" 15 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' 16 | 17 | """Characteristics for title splitting by this class""" 18 | TITLE_CHARACTERISTICS = { 19 | 'max_line_width': 32, # Character count to begin splitting titles 20 | 'max_line_count': 3, # Maximum number of lines a title can take up 21 | 'top_heavy': False, # This class uses bottom heavy titling 22 | } 23 | 24 | """Default font and text color for episode title text""" 25 | TITLE_FONT = str(RemoteFile('lyonza', 'TerminalDosis-Bold.ttf')) 26 | TITLE_COLOR = '#FFFFFF' 27 | 28 | """Default characters to replace in the generic font""" 29 | FONT_REPLACEMENTS = { 30 | '[': '(', ']': ')', '(': '[', ')': ']', '―': '-', '…': '...' 31 | } 32 | 33 | """Whether this CardType uses season titles for archival purposes""" 34 | USES_SEASON_TITLE = True 35 | 36 | """Standard class has standard archive name""" 37 | ARCHIVE_NAME = 'Broadcast Ordering Style' 38 | 39 | EPISODE_TEXT_FORMAT = "S{season_number:02}E{episode_number:02}" 40 | 41 | """Source path for the gradient image overlayed over all title cards""" 42 | __GRADIENT_IMAGE = RemoteFile('lyonza', 'GRADIENTABS.png') 43 | 44 | """Default fonts and color for series count text""" 45 | SEASON_COUNT_FONT = RemoteFile('lyonza', 'TerminalDosis-Bold.ttf') 46 | EPISODE_COUNT_FONT = RemoteFile('lyonza', 'TerminalDosis-Bold.ttf') 47 | SERIES_COUNT_TEXT_COLOR = '#FFFFFF' 48 | 49 | __slots__ = ( 50 | 'source_file', 'output_file', 'title_text', 'episode_text', 51 | 'hide_season_text', 'font_file', 'font_size', 'font_color', 52 | 'font_vertical_shift', 'font_interline_spacing', 'font_kerning', 53 | 'font_stroke_width', 'episode_text_color', 'omit_gradient', 54 | ) 55 | 56 | 57 | def __init__(self, *, 58 | source_file: Path, 59 | card_file: Path, 60 | title_text: str, 61 | episode_text: str, 62 | font_color: str, 63 | font_file: str, 64 | font_interline_spacing: int = 0, 65 | font_kerning: float = 1.0, 66 | font_size: float, 67 | font_stroke_width: float = 1.0, 68 | font_vertical_shift: int = 0, 69 | blur: bool = False, 70 | grayscale: bool = False, 71 | episode_text_color: str = SERIES_COUNT_TEXT_COLOR, 72 | omit_gradient: bool = False, 73 | **unused) -> None: 74 | 75 | # Initialize the parent class - this sets up an ImageMagickInterface 76 | super().__init__(blur, grayscale) 77 | 78 | self.source_file = source_file 79 | self.output_file = card_file 80 | 81 | # Ensure characters that need to be escaped are 82 | self.title_text = self.image_magick.escape_chars(title_text) 83 | self.episode_text = self.image_magick.escape_chars(episode_text.upper()) 84 | 85 | self.font_color = font_color 86 | self.font_file = font_file 87 | self.font_interline_spacing = font_interline_spacing 88 | self.font_kerning = font_kerning 89 | self.font_size = font_size 90 | self.font_stroke_width = font_stroke_width 91 | self.font_vertical_shift = font_vertical_shift 92 | 93 | self.episode_text_color = episode_text_color 94 | self.omit_gradient = omit_gradient 95 | 96 | 97 | @property 98 | def title_text_command(self) -> ImageMagickCommands: 99 | """ 100 | Add episode title text to the provide image. 101 | """ 102 | 103 | font_size = 180 * self.font_size 104 | interline_spacing = -17 + self.font_interline_spacing 105 | kerning = -1.25 * self.font_kerning 106 | stroke_width = 3.0 * self.font_stroke_width 107 | vertical_shift = 50 + self.font_vertical_shift 108 | 109 | return [ 110 | # Global text effects 111 | f'-font "{self.font_file}"', 112 | f'-kerning {kerning}', 113 | f'-interword-spacing 50', 114 | f'-interline-spacing {interline_spacing}', 115 | f'-pointsize {font_size}', 116 | f'-gravity south', 117 | # Black stroke 118 | f'-fill black', 119 | f'-stroke black', 120 | f'-strokewidth {stroke_width}', 121 | f'-annotate +0+{vertical_shift} "{self.title_text}"', 122 | # Actual title text 123 | f'-fill "{self.font_color}"', 124 | f'-annotate +0+{vertical_shift} "{self.title_text}"', 125 | ] 126 | 127 | 128 | @property 129 | def index_text_command(self) -> ImageMagickCommands: 130 | """ 131 | Adds the series count text without season title/number. 132 | """ 133 | 134 | return [ 135 | # Global text effects 136 | f'+interword-spacing', 137 | f'-kerning 5.42', 138 | f'-pointsize 120', 139 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 140 | f'-gravity west', 141 | # Add black stroke 142 | f'-fill black', 143 | f'-stroke black', 144 | f'-strokewidth 6', 145 | f'-annotate +100-750 "{self.episode_text}"', 146 | # Add actual episode text 147 | f'-fill "{self.episode_text_color}"', 148 | f'-stroke black', 149 | f'-strokewidth 0.75', 150 | f'-annotate +100-750 "{self.episode_text}"', 151 | ] 152 | 153 | 154 | @staticmethod 155 | def is_custom_font(font: 'Font') -> bool: 156 | """ 157 | Determines whether the given font characteristics constitute a 158 | default or custom font. 159 | 160 | Args: 161 | font: The Font being evaluated. 162 | 163 | Returns: 164 | True if a custom font is indicated, False otherwise. 165 | """ 166 | 167 | return ((font.color != WhiteTextBroadcast.TITLE_COLOR) 168 | or (font.file != WhiteTextBroadcast.TITLE_FONT) 169 | or (font.interline_spacing != 0) 170 | or (font.kerning != 1.0) 171 | or (font.size != 1.0) 172 | or (font.stroke_width != 1.0) 173 | or (font.vertical_shift != 0) 174 | ) 175 | 176 | 177 | @staticmethod 178 | def is_custom_season_titles( 179 | custom_episode_map: bool, episode_text_format: str) -> bool: 180 | """ 181 | Determines whether the given attributes constitute custom or 182 | generic season titles. 183 | 184 | Args: 185 | custom_episode_map: Whether the EpisodeMap was customized. 186 | episode_text_format: The episode text format in use. 187 | 188 | Returns: 189 | False. Custom season titles are not used. 190 | """ 191 | 192 | return False 193 | 194 | 195 | def create(self) -> None: 196 | """ 197 | Make the necessary ImageMagick and system calls to create this 198 | object's defined title card. 199 | """ 200 | 201 | if self.omit_gradient: 202 | gradient_command = [] 203 | else: 204 | gradient_command = [ 205 | f'"{self.__GRADIENT_IMAGE.resolve()}"', 206 | f'-composite', 207 | ] 208 | 209 | command = ' '.join([ 210 | f'convert "{self.source_file.resolve()}"', 211 | # Overlay gradient 212 | *self.resize_and_style, 213 | *gradient_command, 214 | *self.title_text_command, 215 | *self.index_text_command, 216 | # Resize and write output 217 | *self.resize_output, 218 | f'"{self.output_file.resolve()}"', 219 | ]) 220 | 221 | self.image_magick.run(command) -------------------------------------------------------------------------------- /Yozora/RetroTitleCard.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Literal 3 | 4 | from modules.BaseCardType import BaseCardType, ImageMagickCommands 5 | from modules.Debug import log 6 | from modules.RemoteFile import RemoteFile 7 | 8 | OverrideBW = Literal['', 'bw', 'color'] 9 | OverrideStyle = Literal['', 'rewind', 'play'] 10 | 11 | class RetroTitleCard(BaseCardType): 12 | """ 13 | This class describes a CardType designed by Yozora. This card type 14 | is retro-themed, and features either a Rewind/Play overlay. 15 | """ 16 | 17 | """Directory where all reference files used by this card are stored""" 18 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' / 'retro' 19 | 20 | """Characteristics for title splitting by this class""" 21 | TITLE_CHARACTERISTICS = { 22 | 'max_line_width': 32, # Character count to begin splitting titles 23 | 'max_line_count': 3, # Maximum number of lines a title can take up 24 | 'top_heavy': False, # This class uses bottom heavy titling 25 | } 26 | 27 | """Default font characteristics for the title text""" 28 | TITLE_FONT = str(RemoteFile('Yozora', 'ref/retro/retro.ttf')) 29 | TITLE_COLOR = '#FFFFFF' 30 | FONT_REPLACEMENTS = { 31 | '[': '(', ']': ')', '(': '[', ')': ']', '―': '-', '…': '...' 32 | } 33 | 34 | """Whether this CardType uses season titles for archival purposes""" 35 | USES_SEASON_TITLE = True 36 | 37 | """Standard class has standard archive name""" 38 | ARCHIVE_NAME = 'Retro Style' 39 | 40 | EPISODE_TEXT_FORMAT = "S{season_number:02}E{episode_number:02}" 41 | 42 | """Source path for the gradient image overlayed over all title cards""" 43 | __GRADIENT_IMAGE_PLAY = RemoteFile('Yozora', 'ref/retro/gradient_play.png') 44 | __GRADIENT_IMAGE_REWIND = RemoteFile('Yozora', 'ref/retro/gradient_rewind.png') 45 | 46 | """Default fonts and color for series count text""" 47 | SEASON_COUNT_FONT = RemoteFile('Yozora', 'ref/retro/retro.ttf') 48 | EPISODE_COUNT_FONT = RemoteFile('Yozora', 'ref/retro/retro.ttf') 49 | SERIES_COUNT_TEXT_COLOR = '#FFFFFF' 50 | 51 | __slots__ = ( 52 | 'source_file', 'output_file', 'title_text', 'episode_text', 'font_file', 53 | 'font_size', 'font_color', 'font_vertical_shift', 54 | 'font_interline_spacing', 'font_kerning', 'font_stroke_width', 55 | 'override_bw', 'override_style', 'watched', 56 | ) 57 | 58 | 59 | def __init__(self, *, 60 | source_file: Path, 61 | card_file: Path, 62 | title_text: str, 63 | episode_text: str, 64 | font_color: str = TITLE_COLOR, 65 | font_file: str = TITLE_FONT, 66 | font_interline_spacing: int = 0, 67 | font_kerning: float = 1.0, 68 | font_size: float = 1.0, 69 | font_stroke_width: float = 1.0, 70 | font_vertical_shift: int = 0, 71 | watched: bool = True, 72 | blur: bool = False, 73 | grayscale: bool = False, 74 | override_bw: OverrideBW = '', 75 | override_style: OverrideStyle = '', 76 | **unused) -> None: 77 | 78 | # Initialize the parent class - this sets up an ImageMagickInterface 79 | super().__init__(blur, grayscale) 80 | 81 | self.source_file = source_file 82 | self.output_file = card_file 83 | 84 | # Ensure characters that need to be escaped are 85 | self.title_text = self.image_magick.escape_chars(title_text) 86 | self.episode_text = self.image_magick.escape_chars(episode_text.upper()) 87 | 88 | self.font_color = font_color 89 | self.font_file = font_file 90 | self.font_interline_spacing = font_interline_spacing 91 | self.font_kerning = font_kerning 92 | self.font_size = font_size 93 | self.font_stroke_width = font_stroke_width 94 | self.font_vertical_shift = font_vertical_shift 95 | 96 | # Store extras 97 | self.watched = watched 98 | self.override_bw = override_bw.lower() 99 | self.override_style = override_style.lower() 100 | 101 | 102 | @property 103 | def add_gradient_commands(self) -> ImageMagickCommands: 104 | """ 105 | Add the static gradient to this object's source image. 106 | 107 | Returns: 108 | Path to the created image. 109 | """ 110 | 111 | # Select gradient overlay based on override/watch status 112 | if self.override_style == 'rewind': 113 | gradient_image = self.__GRADIENT_IMAGE_REWIND 114 | elif self.override_style == 'play': 115 | gradient_image = self.__GRADIENT_IMAGE_PLAY 116 | elif self.watched: 117 | gradient_image = self.__GRADIENT_IMAGE_REWIND 118 | else: 119 | gradient_image = self.__GRADIENT_IMAGE_PLAY 120 | 121 | # Determine colorspace (B+W/color) on override/watch status 122 | if self.override_bw == 'bw': 123 | colorspace = '-colorspace gray' 124 | elif self.override_bw == 'color': 125 | colorspace = '' 126 | elif self.watched: 127 | colorspace = '-colorspace gray' 128 | else: 129 | colorspace = '' 130 | 131 | return [ 132 | f'"{gradient_image.resolve()}"', 133 | f'-composite', 134 | f'{colorspace}', 135 | ] 136 | 137 | 138 | @property 139 | def title_text_commands(self) -> ImageMagickCommands: 140 | """ 141 | Adds episode title text to the provide image. 142 | 143 | Returns: 144 | List of ImageMagick commands. 145 | """ 146 | 147 | font_size = 150 * self.font_size 148 | interline_spacing = -17 + self.font_interline_spacing 149 | kerning = -1.25 * self.font_kerning 150 | stroke_width = 3.0 * self.font_stroke_width 151 | vertical_shift = 170 + self.font_vertical_shift 152 | 153 | return [ 154 | f'-font "{self.font_file}"', 155 | f'-kerning {kerning}', 156 | f'-interword-spacing 50', 157 | f'-interline-spacing {interline_spacing}', 158 | f'-pointsize {font_size}', 159 | f'-gravity southwest', 160 | f'-fill black', 161 | f'-stroke black', 162 | f'-strokewidth {stroke_width}', 163 | f'-annotate +229+{vertical_shift} "{self.title_text}"', 164 | f'-fill "{self.font_color}"', 165 | f'-annotate +229+{vertical_shift} "{self.title_text}"', 166 | ] 167 | 168 | 169 | @property 170 | def index_text_commands(self) -> ImageMagickCommands: 171 | """ 172 | Adds the series count text. 173 | 174 | Returns: 175 | List of ImageMagick commands 176 | """ 177 | 178 | return [ 179 | f'-kerning 5.42', 180 | f'-pointsize 100', 181 | f'+interword-spacing', 182 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 183 | f'-gravity northeast', 184 | f'-fill black', 185 | f'-stroke black', 186 | f'-strokewidth 6', 187 | f'-annotate +200+229 "{self.episode_text}"', 188 | f'-fill white', 189 | f'-stroke black', 190 | f'-strokewidth 0.75', 191 | f'-annotate +200+229 "{self.episode_text}"', 192 | ] 193 | 194 | 195 | @staticmethod 196 | def is_custom_font(font: 'Font') -> bool: 197 | """ 198 | Determines whether the given font characteristics constitute a 199 | default or custom font. 200 | 201 | Args: 202 | font: The Font being evaluated. 203 | 204 | Returns: 205 | True if a custom font is indicated, False otherwise. 206 | """ 207 | 208 | return ((font.color != RetroTitleCard.TITLE_COLOR) 209 | or (font.file != RetroTitleCard.TITLE_FONT) 210 | or (font.interline_spacing != 0) 211 | or (font.kerning != 1.0) 212 | or (font.size != 1.0) 213 | or (font.stroke_width != 1.0) 214 | or (font.vertical_shift != 0) 215 | ) 216 | 217 | 218 | @staticmethod 219 | def is_custom_season_titles( 220 | custom_episode_map: bool, episode_text_format: str) -> bool: 221 | """ 222 | Determines whether the given attributes constitute custom or 223 | generic season titles. 224 | 225 | Args: 226 | custom_episode_map: Whether the EpisodeMap was customized. 227 | episode_text_format: The episode text format in use. 228 | 229 | Returns: 230 | False, as custom season titles are not used. 231 | """ 232 | 233 | return False 234 | 235 | 236 | def create(self) -> None: 237 | """ 238 | Make the necessary ImageMagick and system calls to create this 239 | object's defined title card. 240 | """ 241 | 242 | command = ' '.join([ 243 | f'convert "{self.source_file.resolve()}"', 244 | *self.resize_and_style, 245 | *self.add_gradient_commands, 246 | *self.title_text_commands, 247 | *self.index_text_commands, 248 | *self.resize_output, 249 | f'"{self.output_file.resolve()}"', 250 | ]) 251 | 252 | self.image_magick.run(command) -------------------------------------------------------------------------------- /Yozora/BarebonesTitleCard.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from re import match 3 | 4 | from num2words import num2words 5 | 6 | from modules.BaseCardType import BaseCardType 7 | from modules.Debug import log 8 | from modules.RemoteFile import RemoteFile 9 | 10 | class BarebonesTitleCard(BaseCardType): 11 | """ 12 | Yozora's barebones card type that is inspired by the Olivier and 13 | StarWars card types. 14 | """ 15 | 16 | """Directory where all reference files used by this card are stored""" 17 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' / 'barebones' 18 | 19 | """Characteristics for title splitting by this class""" 20 | TITLE_CHARACTERISTICS = { 21 | 'max_line_width': 16, # Character count to begin splitting titles 22 | 'max_line_count': 5, # Maximum number of lines a title can take up 23 | 'top_heavy': True, # This class uses top heavy titling 24 | } 25 | 26 | """Characteristics of the default title font""" 27 | TITLE_FONT = str(RemoteFile('Yozora', 'ref/barebones/Montserrat-Bold.ttf')) 28 | TITLE_COLOR = '#FFFFFF' 29 | FONT_REPLACEMENTS = {} 30 | 31 | """Characteristics of the episode text""" 32 | EPISODE_TEXT_FORMAT = 'EPISODE {episode_number}' 33 | EPISODE_TEXT_COLOR = '#FFFFFF' 34 | EPISODE_TEXT_FONT = RemoteFile('Yozora', 'ref/barebones/Montserrat-SemiBold.ttf') 35 | 36 | """Whether this class uses season titles for the purpose of archives""" 37 | USES_SEASON_TITLE = False 38 | 39 | """How to name archive directories for this type of card""" 40 | ARCHIVE_NAME = 'Barebones Style' 41 | 42 | """Paths to intermediate files that are deleted after the card is created""" 43 | __RESIZED_SOURCE = BaseCardType.TEMP_DIR / 'resized_source.png' 44 | 45 | __slots__ = ( 46 | 'source_file', 'output_file', 'title', 'hide_episode_text', 47 | 'episode_text', 'font', 'font_size', 'title_color', 48 | 'episode_text_color', 'stroke_width' 49 | ) 50 | 51 | 52 | def __init__(self, *, 53 | source_file: Path, 54 | card_file: Path, 55 | title_text: str, 56 | episode_text: str, 57 | hide_episode_text: bool = False, 58 | font_color: str = TITLE_COLOR, 59 | font_file: str = TITLE_FONT, 60 | font_size: float = 1.0, 61 | font_stroke_width: float = 1.0, 62 | blur: bool = False, 63 | grayscale: bool = False, 64 | episode_text_color: str = EPISODE_TEXT_COLOR, 65 | **unused) -> None: 66 | """ 67 | Initialize this CardType object. 68 | """ 69 | 70 | # Initialize the parent class - this sets up an ImageMagickInterface 71 | super().__init__(blur, grayscale) 72 | 73 | # Store source and output file 74 | self.source_file = source_file 75 | self.output_file = card_file 76 | 77 | # Store episode title and text 78 | self.title = self.image_magick.escape_chars(title_text.upper()) 79 | self.hide_episode_text = hide_episode_text or len(episode_text) == 0 80 | 81 | # Attempt to convert episode text number to numeric text 82 | if (not self.hide_episode_text 83 | and (groups := match(r'^(.*?)(\d+)$', episode_text)) is not None): 84 | pre, number = groups.groups() 85 | episode_text = f'{pre}{num2words(int(number))}'.upper() 86 | else: 87 | episode_text = episode_text.upper() 88 | self.episode_text = self.image_magick.escape_chars(episode_text) 89 | 90 | # Font customizations 91 | self.title_color = font_color 92 | self.font = font_file 93 | self.font_size = font_size 94 | self.stroke_width = font_stroke_width 95 | 96 | self.episode_text_color = episode_text_color 97 | 98 | 99 | def __resize_source(self, source: Path) -> Path: 100 | """ 101 | Resize the source image. 102 | 103 | Args: 104 | source: The source image to modify. 105 | 106 | Returns: 107 | Path to the resized image. 108 | """ 109 | 110 | command = ' '.join([ 111 | f'convert "{source.resolve()}"', 112 | *self.resize_and_style, 113 | f'"{self.__RESIZED_SOURCE.resolve()}"', 114 | ]) 115 | 116 | self.image_magick.run(command) 117 | 118 | return self.__RESIZED_SOURCE 119 | 120 | def __add_title_text(self) -> list[str]: 121 | """ 122 | ImageMagick commands to add the episode title text to an image. 123 | 124 | Returns: 125 | List of ImageMagick commands. 126 | """ 127 | 128 | stroke_width = 6.0 * self.stroke_width 129 | font_size = 124 * self.font_size 130 | 131 | return [ 132 | f'\( -font "{self.font}"', 133 | f'-gravity northwest', 134 | f'-pointsize {font_size}', 135 | f'-kerning 0.5', 136 | f'-fill black', 137 | f'-stroke black', 138 | f'-strokewidth {stroke_width}', 139 | f'-annotate +320+829 "{self.title}" \)', 140 | f'\( -fill "{self.title_color}"', 141 | f'-stroke "{self.title_color}"', 142 | f'-strokewidth 0', 143 | f'-annotate +320+829 "{self.title}" \)', 144 | ] 145 | 146 | 147 | def __add_episode_text(self) -> list[str]: 148 | """ 149 | ImageMagick commands to add the episode text to an image. 150 | 151 | Returns: 152 | List of ImageMagick commands. 153 | """ 154 | 155 | return [ 156 | f'-gravity west', 157 | f'-font "{self.EPISODE_TEXT_FONT.resolve()}"', 158 | f'-pointsize 53', 159 | f'-kerning 19', 160 | f'-fill black', 161 | f'-stroke black', 162 | f'-strokewidth 4.5', 163 | f'-annotate +325-140 "{self.episode_text}"', 164 | f'-fill "{self.episode_text_color}"', 165 | f'-stroke "{self.episode_text_color}"', 166 | f'-strokewidth 0', 167 | f'-annotate +325-140 "{self.episode_text}"', 168 | ] 169 | 170 | 171 | def __add_only_title(self, resized_source: Path) -> Path: 172 | """ 173 | Add the title to the given image. 174 | 175 | Args: 176 | resized_source: Resized source image. 177 | 178 | Returns: 179 | Path to the created image (the output file). 180 | """ 181 | 182 | command = ' '.join([ 183 | f'convert "{resized_source.resolve()}"', 184 | *self.__add_title_text(), 185 | *self.resize_output, 186 | f'"{self.output_file.resolve()}"', 187 | ]) 188 | 189 | self.image_magick.run(command) 190 | 191 | return self.output_file 192 | 193 | 194 | def __add_all_text(self, resized_source: Path) -> Path: 195 | """ 196 | Add the title and episode text to the given image. 197 | 198 | Args: 199 | resized_source: Resized source image. 200 | 201 | Returns: 202 | Path to the created image (the output file). 203 | """ 204 | 205 | command = ' '.join([ 206 | f'convert "{resized_source.resolve()}"', 207 | *self.__add_title_text(), 208 | *self.__add_episode_text(), 209 | *self.resize_output, 210 | f'"{self.output_file.resolve()}"', 211 | ]) 212 | 213 | self.image_magick.run(command) 214 | 215 | return self.output_file 216 | 217 | 218 | @staticmethod 219 | def is_custom_font(font: 'Font') -> bool: 220 | """ 221 | Determines whether the given font characteristics constitute a 222 | default or custom font. 223 | 224 | Args: 225 | font: The Font being evaluated. 226 | 227 | Returns: 228 | True if a custom font is indicated, False otherwise. 229 | """ 230 | 231 | return ((font.color != BarebonesTitleCard.TITLE_COLOR) 232 | or (font.file != BarebonesTitleCard.TITLE_FONT) 233 | or (font.size != 1.0) 234 | or (font.stroke_width != 1.0) 235 | ) 236 | 237 | 238 | @staticmethod 239 | def is_custom_season_titles( 240 | custom_episode_map: bool, episode_text_format: str) -> bool: 241 | """ 242 | Determines whether the given attributes constitute custom or 243 | generic season titles. 244 | 245 | Args: 246 | custom_episode_map: Whether the EpisodeMap was customized. 247 | episode_text_format: The episode text format in use. 248 | 249 | Returns: 250 | True if custom season titles are indicated, False otherwise. 251 | """ 252 | 253 | standard_etf = BarebonesTitleCard.EPISODE_TEXT_FORMAT.upper() 254 | 255 | return episode_text_format.upper() != standard_etf 256 | 257 | 258 | def create(self) -> None: 259 | """Create the title card as defined by this object.""" 260 | 261 | # Add the starry gradient to the source image 262 | resized_image = self.__resize_source(self.source_file) 263 | 264 | # Add text to starry image, result is output 265 | if self.hide_episode_text: 266 | self.__add_only_title(resized_image) 267 | else: 268 | self.__add_all_text(resized_image) 269 | 270 | # Delete all intermediate images 271 | self.image_magick.delete_intermediate_images(resized_image) -------------------------------------------------------------------------------- /Wdvh/WhiteTextTitleOnlyLogo.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | from modules.BaseCardType import BaseCardType 5 | from modules.Debug import log 6 | from modules.RemoteFile import RemoteFile 7 | 8 | class WhiteTextTitleOnlyLogo(BaseCardType): 9 | """ 10 | This class describes Wdvh's title only title CardType. 11 | """ 12 | 13 | """Directory where all reference files used by this card are stored""" 14 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' 15 | 16 | """Characteristics for title splitting by this class""" 17 | TITLE_CHARACTERISTICS = { 18 | 'max_line_width': 32, # Character count to begin splitting titles 19 | 'max_line_count': 3, # Maximum number of lines a title can take up 20 | 'top_heavy': False, # This class uses bottom heavy titling 21 | } 22 | 23 | """Default font and text color for episode title text""" 24 | TITLE_FONT = str(RemoteFile('Wdvh', 'TerminalDosis-Bold.ttf')) 25 | TITLE_COLOR = '#FFFFFF' 26 | 27 | """Default characters to replace in the generic font""" 28 | FONT_REPLACEMENTS = {} 29 | 30 | """Whether this CardType uses season titles for archival purposes""" 31 | USES_SEASON_TITLE = False 32 | 33 | """Whether this CardType uses unique source images""" 34 | USES_UNIQUE_SOURCES = False 35 | 36 | """Standard class has standard archive name""" 37 | ARCHIVE_NAME = 'White Text Title Only Logo Style' 38 | 39 | """Default fonts and color for series count text""" 40 | SEASON_COUNT_FONT = REF_DIRECTORY / 'Sequel-Neue.otf' 41 | EPISODE_COUNT_FONT = REF_DIRECTORY / 'Sequel-Neue.otf' 42 | SERIES_COUNT_TEXT_COLOR = '#FFFFFF' 43 | 44 | """Paths to intermediate files that are deleted after the card is created""" 45 | __RESIZED_LOGO = BaseCardType.TEMP_DIR / 'resized_logo.png' 46 | __BACKDROP_WITH_LOGO = BaseCardType.TEMP_DIR / 'backdrop_logo.png' 47 | 48 | __slots__ = ( 49 | 'logo', 'output_file', 'title', 'font', 'font_size', 'title_color', 50 | 'vertical_shift', 'interline_spacing', 'kerning', 'stroke_width', 51 | 'background', 52 | ) 53 | 54 | 55 | def __init__(self, *, 56 | card_file: Path, 57 | title_text: str, 58 | font_color: str = TITLE_COLOR, 59 | font_file: str = TITLE_FONT, 60 | font_kerning: float = 1.0, 61 | font_interline_spacing: int = 0, 62 | font_size: float = 1.0, 63 | font_stroke_width: float = 1.0, 64 | font_vertical_shift: int = 0, 65 | blur: bool = False, 66 | grayscale: bool = False, 67 | logo: Optional[str] = None, 68 | background: str = '#000000', 69 | **unused) -> None: 70 | """ 71 | Initialize this CardType object. 72 | """ 73 | 74 | # Initialize the parent class - this sets up an ImageMagickInterface 75 | super().__init__(blur, grayscale) 76 | 77 | # Convert logo to Path 78 | if isinstance(logo, str): 79 | self.logo = Path(logo) 80 | else: 81 | self.logo = None 82 | 83 | self.output_file = card_file 84 | 85 | # Ensure characters that need to be escaped are 86 | self.title = self.image_magick.escape_chars(title_text) 87 | 88 | self.font = font_file 89 | self.title_color = font_color 90 | self.interline_spacing = font_interline_spacing 91 | self.kerning = font_kerning 92 | self.font_size = font_size 93 | self.stroke_width = font_stroke_width 94 | self.vertical_shift = font_vertical_shift 95 | 96 | self.background = background 97 | 98 | 99 | def __title_text_global_effects(self) -> list[str]: 100 | """ 101 | ImageMagick commands to implement the title text's global effects. 102 | Specifically the the font, kerning, fontsize, and center gravity. 103 | 104 | Returns: 105 | List of ImageMagick commands. 106 | """ 107 | 108 | font_size = 180 * self.font_size 109 | interline_spacing = -17 + self.interline_spacing 110 | kerning = -1.25 * self.kerning 111 | 112 | return [ 113 | f'-font "{self.font}"', 114 | f'-kerning {kerning}', 115 | f'-interword-spacing 50', 116 | f'-interline-spacing {interline_spacing}', 117 | f'-pointsize {font_size}', 118 | f'-gravity south', 119 | ] 120 | 121 | 122 | def __title_text_black_stroke(self) -> list[str]: 123 | """ 124 | ImageMagick commands to implement the title text's black stroke. 125 | 126 | Returns: 127 | List of ImageMagick commands. 128 | """ 129 | 130 | stroke_width = 4.0 * self.stroke_width 131 | 132 | return [ 133 | f'-fill white', 134 | f'-stroke "#062A40"', 135 | f'-strokewidth {stroke_width}', 136 | ] 137 | 138 | 139 | def _resize_logo(self) -> Path: 140 | """ 141 | Resize the logo into at most a 1875x1030 bounding box. 142 | 143 | Returns: 144 | Path to the created image. 145 | """ 146 | 147 | command = ' '.join([ 148 | f'convert', 149 | f'"{self.logo.resolve()}"', 150 | f'-resize x1030', 151 | f'-resize 1875x1030\>', 152 | f'"{self.__RESIZED_LOGO.resolve()}"', 153 | ]) 154 | 155 | self.image_magick.run(command) 156 | 157 | return self.__RESIZED_LOGO 158 | 159 | 160 | def _add_logo_to_backdrop(self, resized_logo: Path) -> Path: 161 | """ 162 | Add the resized logo to a fixed color backdrop. 163 | 164 | Returns: 165 | Path to the created image. 166 | """ 167 | 168 | # Get height of the resized logo to determine offset 169 | height_command = ' '.join([ 170 | f'identify', 171 | f'-format "%h"', 172 | f'"{resized_logo.resolve()}"', 173 | ]) 174 | 175 | height = int(self.image_magick.run_get_output(height_command)) 176 | 177 | # Get offset of where to place logo onto card 178 | offset = 60 + ((1030 - height) // 2) 179 | 180 | command = ' '.join([ 181 | f'convert', 182 | f'-size "{self.TITLE_CARD_SIZE}"', # Create backdrop 183 | f'xc:"{self.background}"', # Fill canvas with color 184 | f'"{resized_logo.resolve()}"', 185 | f'-set colorspace sRGB', 186 | f'-gravity north', 187 | f'-geometry "+0+{offset}"', # Put logo on backdrop 188 | f'-composite "{self.__BACKDROP_WITH_LOGO.resolve()}"', 189 | ]) 190 | 191 | self.image_magick.run(command) 192 | 193 | return self.__BACKDROP_WITH_LOGO 194 | 195 | 196 | def _add_title_text(self, backdrop_logo: Path) -> Path: 197 | """ 198 | Adds episode title text to the provide image. 199 | 200 | :param backdrop_logo: The backdrop and logo image. 201 | 202 | :returns: Path to the created image that has the title text added. 203 | """ 204 | 205 | vertical_shift = 245 + self.vertical_shift 206 | 207 | command = ' '.join([ 208 | f'convert "{backdrop_logo.resolve()}"', 209 | *self.resize_and_style, 210 | *self.__title_text_global_effects(), 211 | *self.__title_text_black_stroke(), 212 | f'-annotate +0+{vertical_shift} "{self.title}"', 213 | f'-fill "{self.title_color}"', 214 | f'-annotate +0+{vertical_shift} "{self.title}"', 215 | *self.resize_output, 216 | f'"{self.output_file.resolve()}"', 217 | ]) 218 | 219 | self.image_magick.run(command) 220 | 221 | return self.output_file 222 | 223 | @staticmethod 224 | def is_custom_font(font: 'Font') -> bool: 225 | """ 226 | Determines whether the given font characteristics constitute a 227 | default or custom font. 228 | 229 | Args: 230 | font: The Font being evaluated. 231 | 232 | Returns: 233 | True if a custom font is indicated, False otherwise. 234 | """ 235 | 236 | return ((font.file != WhiteTextTitleOnlyLogo.TITLE_FONT) 237 | or (font.size != 1.0) 238 | or (font.color != WhiteTextTitleOnlyLogo.TITLE_COLOR) 239 | or (font.replacements != WhiteTextTitleOnlyLogo.FONT_REPLACEMENTS) 240 | or (font.vertical_shift != 0) 241 | or (font.interline_spacing != 0) 242 | or (font.kerning != 1.0) 243 | or (font.stroke_width != 1.0)) 244 | 245 | 246 | @staticmethod 247 | def is_custom_season_titles( 248 | custom_episode_map: bool, episode_text_format: str) -> bool: 249 | """ 250 | Determines whether the given attributes constitute custom or 251 | generic season titles. 252 | 253 | Args: 254 | custom_episode_map: Whether the EpisodeMap was customized. 255 | episode_text_format: The episode text format in use. 256 | 257 | Returns: 258 | False, as custom season titles are not used. 259 | """ 260 | 261 | return False 262 | 263 | 264 | def create(self) -> None: 265 | """ 266 | Make the necessary ImageMagick and system calls to create this 267 | object's defined title card. 268 | """ 269 | 270 | # Skip card if logo doesn't exist 271 | if self.logo is None: 272 | log.error(f'Logo file not specified') 273 | return None 274 | elif not self.logo.exists(): 275 | log.error(f'Logo file "{self.logo.resolve()}" does not exist') 276 | return None 277 | 278 | # Resize logo 279 | resized_logo = self._resize_logo() 280 | 281 | # Create backdrop+logo image 282 | backdrop_logo = self._add_logo_to_backdrop(resized_logo) 283 | 284 | # Add either one or two lines of episode text 285 | self._add_title_text(backdrop_logo) 286 | 287 | # Delete all intermediate images 288 | self.image_magick.delete_intermediate_images(resized_logo,backdrop_logo) -------------------------------------------------------------------------------- /Wdvh/WhiteTextAbsolute.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from re import findall 3 | 4 | from modules.BaseCardType import BaseCardType 5 | from modules.Debug import log 6 | from modules.RemoteFile import RemoteFile 7 | 8 | class WhiteTextAbsolute(BaseCardType): 9 | """ 10 | This class describes Wdvh's absolute CardType intended for absolute 11 | episode ordering 12 | """ 13 | 14 | """Directory where all reference files used by this card are stored""" 15 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' 16 | 17 | """Characteristics for title splitting by this class""" 18 | TITLE_CHARACTERISTICS = { 19 | 'max_line_width': 32, # Character count to begin splitting titles 20 | 'max_line_count': 3, # Maximum number of lines a title can take up 21 | 'top_heavy': False, # This class uses bottom heavy titling 22 | } 23 | 24 | """Default font and text color for episode title text""" 25 | TITLE_FONT = str(RemoteFile('Wdvh', 'TerminalDosis-Bold.ttf')) 26 | TITLE_COLOR = '#FFFFFF' 27 | 28 | """Default characters to replace in the generic font""" 29 | FONT_REPLACEMENTS = {} 30 | 31 | """Whether this CardType uses season titles for archival purposes""" 32 | USES_SEASON_TITLE = False 33 | 34 | """Standard class has standard archive name""" 35 | ARCHIVE_NAME = 'White Text Absolute Ordering Style' 36 | 37 | EPISODE_TEXT_FORMAT = "E{abs_number:02}" 38 | 39 | """Source path for the gradient image overlayed over all title cards""" 40 | __GRADIENT_IMAGE = RemoteFile('Wdvh', 'GRADIENTABS.png') 41 | 42 | """Default fonts and color for series count text""" 43 | SEASON_COUNT_FONT = REF_DIRECTORY / 'Sequel-Neue.otf' 44 | EPISODE_COUNT_FONT = REF_DIRECTORY / 'Sequel-Neue.otf' 45 | SERIES_COUNT_TEXT_COLOR = '#FFFFFF' 46 | 47 | """Paths to intermediate files that are deleted after the card is created""" 48 | __SOURCE_WITH_GRADIENT = BaseCardType.TEMP_DIR / 'source_gradient.png' 49 | __GRADIENT_WITH_TITLE = BaseCardType.TEMP_DIR / 'gradient_title.png' 50 | 51 | __slots__ = ( 52 | 'source_file', 'output_file', 'title', 'episode_text', 'font', 53 | 'font_size', 'title_color', 'vertical_shift', 'interline_spacing', 54 | 'kerning', 'stroke_width' 55 | ) 56 | 57 | 58 | def __init__(self, *, 59 | source_file: Path, 60 | card_file: Path, 61 | title_text: str, 62 | episode_text: str, 63 | font_color: str = TITLE_COLOR, 64 | font_file: str = TITLE_FONT, 65 | font_size: float = 1.0, 66 | font_interline_spacing: int = 0, 67 | font_kerning: float = 1.0, 68 | font_stroke_width: float = 1.0, 69 | font_vertical_shift: int = 0, 70 | blur: bool = False, 71 | grayscale: bool = False, 72 | **unused) -> None: 73 | """ 74 | Initialize this CardType object. 75 | """ 76 | 77 | # Initialize the parent class - this sets up an ImageMagickInterface 78 | super().__init__(blur, grayscale) 79 | 80 | self.source_file = source_file 81 | self.output_file = card_file 82 | 83 | # Ensure characters that need to be escaped are 84 | self.title = self.image_magick.escape_chars(title_text) 85 | self.episode_text = self.image_magick.escape_chars(episode_text.upper()) 86 | 87 | self.font = font_file 88 | self.title_color = font_color 89 | self.interline_spacing = font_interline_spacing 90 | self.kerning = font_kerning 91 | self.font_size = font_size 92 | self.stroke_width = font_stroke_width 93 | self.vertical_shift = font_vertical_shift 94 | 95 | 96 | def __title_text_global_effects(self) -> list[str]: 97 | """ 98 | ImageMagick commands to implement the title text's global effects. 99 | Specifically the the font, kerning, fontsize, and center gravity. 100 | 101 | Returns: 102 | List of ImageMagick commands. 103 | """ 104 | 105 | font_size = 180 * self.font_size 106 | interline_spacing = -17 + self.interline_spacing 107 | kerning = -1.25 * self.kerning 108 | 109 | return [ 110 | f'-font "{self.font}"', 111 | f'-kerning {kerning}', 112 | f'-interword-spacing 50', 113 | f'-interline-spacing {interline_spacing}', 114 | f'-pointsize {font_size}', 115 | f'-gravity south', 116 | ] 117 | 118 | 119 | def __title_text_black_stroke(self) -> list[str]: 120 | """ 121 | ImageMagick commands to implement the title text's black stroke. 122 | 123 | Returns: 124 | List of ImageMagick commands. 125 | """ 126 | 127 | stroke_width = 4.0 * self.stroke_width 128 | 129 | return [ 130 | f'-fill white', 131 | f'-stroke "#062A40"', 132 | f'-strokewidth {stroke_width}', 133 | ] 134 | 135 | 136 | def __series_count_text_global_effects(self) -> list[str]: 137 | """ 138 | ImageMagick commands for global text effects applied to all series count 139 | text (season/episode count and dot). 140 | 141 | Returns: 142 | List of ImageMagick commands. 143 | """ 144 | 145 | return [ 146 | f'-kerning 5.42', 147 | f'-pointsize 120', 148 | ] 149 | 150 | 151 | def __series_count_text_black_stroke(self) -> list[str]: 152 | """ 153 | ImageMagick commands for adding the necessary black stroke effects to 154 | series count text. 155 | 156 | Returns: 157 | List of ImageMagick commands. 158 | """ 159 | 160 | return [ 161 | f'-fill white', 162 | f'-stroke "#062A40"', 163 | f'-strokewidth 2', 164 | ] 165 | 166 | 167 | def __series_count_text_effects(self) -> list[str]: 168 | """ 169 | ImageMagick commands for adding the necessary text effects to the series 170 | count text. 171 | 172 | Returns: 173 | List of ImageMagick commands. 174 | """ 175 | 176 | return [ 177 | f'-fill white', 178 | f'-stroke "#062A40"', 179 | f'-strokewidth 2', 180 | ] 181 | 182 | 183 | def _add_gradient(self) -> Path: 184 | """ 185 | Add the static gradient to this object's source image. 186 | 187 | Returns: 188 | Path to the created image. 189 | """ 190 | 191 | command = ' '.join([ 192 | f'convert "{self.source_file.resolve()}"', 193 | *self.resize_and_style, 194 | f'"{self.__GRADIENT_IMAGE.resolve()}"', 195 | f'-background None', 196 | f'-layers Flatten', 197 | f'"{self.__SOURCE_WITH_GRADIENT.resolve()}"', 198 | ]) 199 | 200 | self.image_magick.run(command) 201 | 202 | return self.__SOURCE_WITH_GRADIENT 203 | 204 | 205 | def _add_title_text(self, gradient_image: Path) -> Path: 206 | """ 207 | Adds episode title text to the provide image. 208 | 209 | :param gradient_image: The image with gradient added. 210 | 211 | :returns: Path to the created image that has a gradient and the title 212 | text added. 213 | """ 214 | 215 | vertical_shift = 50 + self.vertical_shift 216 | 217 | command = ' '.join([ 218 | f'convert "{gradient_image.resolve()}"', 219 | *self.__title_text_global_effects(), 220 | *self.__title_text_black_stroke(), 221 | f'-annotate +0+{vertical_shift} "{self.title}"', 222 | f'-fill "{self.title_color}"', 223 | f'-annotate +0+{vertical_shift} "{self.title}"', 224 | f'"{self.__GRADIENT_WITH_TITLE.resolve()}"', 225 | ]) 226 | 227 | self.image_magick.run(command) 228 | 229 | return self.__GRADIENT_WITH_TITLE 230 | 231 | 232 | def _add_series_count_text_no_season(self, titled_image: Path) -> Path: 233 | """ 234 | Adds the series count text without season title/number. 235 | 236 | :param titled_image: The titled image to add text to. 237 | 238 | :returns: Path to the created image (the output file). 239 | """ 240 | 241 | command = ' '.join([ 242 | f'convert "{titled_image.resolve()}"', 243 | *self.__series_count_text_global_effects(), 244 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 245 | f'-gravity west', 246 | *self.__series_count_text_black_stroke(), 247 | f'-annotate +100-750 "{self.episode_text}"', 248 | *self.__series_count_text_effects(), 249 | f'-annotate +100-750 "{self.episode_text}"', 250 | *self.resize_output, 251 | f'"{self.output_file.resolve()}"', 252 | ]) 253 | 254 | self.image_magick.run(command) 255 | 256 | return self.output_file 257 | 258 | 259 | @staticmethod 260 | def is_custom_font(font: 'Font') -> bool: 261 | """ 262 | Determines whether the given font characteristics constitute a 263 | default or custom font. 264 | 265 | Args: 266 | font: The Font being evaluated. 267 | 268 | Returns: 269 | True if a custom font is indicated, False otherwise. 270 | """ 271 | 272 | return ((font.file != WhiteTextAbsolute.TITLE_FONT) 273 | or (font.size != 1.0) 274 | or (font.color != WhiteTextAbsolute.TITLE_COLOR) 275 | or (font.replacements != WhiteTextAbsolute.FONT_REPLACEMENTS) 276 | or (font.vertical_shift != 0) 277 | or (font.interline_spacing != 0) 278 | or (font.kerning != 1.0) 279 | or (font.stroke_width != 1.0)) 280 | 281 | 282 | @staticmethod 283 | def is_custom_season_titles( 284 | custom_episode_map: bool, episode_text_format: str) -> bool: 285 | """ 286 | Determines whether the given attributes constitute custom or 287 | generic season titles. 288 | 289 | Args: 290 | custom_episode_map: Whether the EpisodeMap was customized. 291 | episode_text_format: The episode text format in use. 292 | 293 | Returns: 294 | False. Custom season titles are not used. 295 | """ 296 | 297 | return False 298 | 299 | 300 | def create(self) -> None: 301 | """ 302 | Make the necessary ImageMagick and system calls to create this 303 | object's defined title card. 304 | """ 305 | 306 | # Add the gradient to the source image (always) 307 | gradient_image = self._add_gradient() 308 | 309 | # Add either one or two lines of episode text 310 | titled_image = self._add_title_text(gradient_image) 311 | 312 | # Add episode text 313 | self._add_series_count_text_no_season(titled_image) 314 | 315 | # Delete all intermediate images 316 | self.image_magick.delete_intermediate_images(gradient_image, titled_image) -------------------------------------------------------------------------------- /Yozora/SlimTitleCard.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from re import findall 3 | 4 | from modules.BaseCardType import BaseCardType, ImageMagickCommands 5 | from modules.RemoteFile import RemoteFile 6 | from modules.Debug import log 7 | 8 | class SlimTitleCard(BaseCardType): 9 | """ 10 | 11 | """ 12 | 13 | """Directory where all reference files used by this card are stored""" 14 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' 15 | 16 | """Characteristics for title splitting by this class""" 17 | TITLE_CHARACTERISTICS = { 18 | 'max_line_width': 45, # Character count to begin splitting titles 19 | 'max_line_count': 3, # Maximum number of lines a title can take up 20 | 'top_heavy': False, # This class uses bottom heavy titling 21 | } 22 | 23 | """Default font and text color for episode title text""" 24 | TITLE_FONT = str(RemoteFile('Yozora', 'ref/slim/Comfortaa-Regular.ttf')) 25 | TITLE_COLOR = '#FFFFFF' 26 | 27 | """Default characters to replace in the generic font""" 28 | FONT_REPLACEMENTS = { 29 | '…': '...', '[': '(', ']': ')', '(': '[', ')': ']', '―': '-' 30 | } 31 | 32 | """Whether this CardType uses season titles for archival purposes""" 33 | USES_SEASON_TITLE = True 34 | 35 | """Slim class has specialized archive name""" 36 | ARCHIVE_NAME = 'Slim Style' 37 | 38 | """Source path for the gradient image overlayed over all title cards""" 39 | __GRADIENT_IMAGE = RemoteFile('Yozora', 'ref/slim/GRADIENT.png') 40 | 41 | """Default fonts and color for series count text""" 42 | SEASON_COUNT_FONT = RemoteFile('Yozora', 'ref/slim/Comfortaa-SemiBold.ttf') 43 | EPISODE_COUNT_FONT = RemoteFile('Yozora', 'ref/slim/Comfortaa-Regular.ttf') 44 | SERIES_COUNT_TEXT_COLOR = '#a5a5a5' 45 | 46 | __slots__ = ( 47 | 'source_file', 'output_file', 'title_text', 'season_text', 48 | 'episode_text', 'hide_season_text', 'font_color', 'font_file', 49 | 'font_interline_spacing', 'font_kerning', 'font_size', 50 | 'font_stroke_width', 'font_vertical_shift', 51 | ) 52 | 53 | 54 | def __init__(self, *, 55 | source_file: Path, 56 | card_file: Path, 57 | title_text: str, 58 | season_text: str, 59 | episode_text: str, 60 | hide_season_text: bool = False, 61 | font_color: str = TITLE_COLOR, 62 | font_file: str = TITLE_FONT, 63 | font_interline_spacing: int = 0, 64 | font_kerning: float = 1.0, 65 | font_size: float = 1.0, 66 | font_stroke_width: float = 1.0, 67 | font_vertical_shift: int = 0, 68 | blur: bool = False, 69 | grayscale: bool = False, 70 | **unused) -> None: 71 | 72 | # Initialize the parent class - this sets up an ImageMagickInterface 73 | super().__init__(blur, grayscale) 74 | 75 | self.source_file = source_file 76 | self.output_file = card_file 77 | 78 | # Ensure characters that need to be escaped are 79 | self.title_text = self.image_magick.escape_chars(title_text) 80 | self.season_text = self.image_magick.escape_chars(season_text.upper()) 81 | self.episode_text = self.image_magick.escape_chars(episode_text.upper()) 82 | self.hide_season_text = hide_season_text 83 | 84 | self.font_color = font_color 85 | self.font_file = font_file 86 | self.font_interline_spacing = font_interline_spacing 87 | self.font_kerning = font_kerning 88 | self.font_size = font_size 89 | self.font_stroke_width = font_stroke_width 90 | self.font_vertical_shift = font_vertical_shift 91 | 92 | 93 | def __title_text_global_effects(self) -> ImageMagickCommands: 94 | """ 95 | ImageMagick commands to implement the title text's global effects. 96 | Specifically the the font, kerning, fontsize, and center gravity. 97 | 98 | Returns: 99 | List of ImageMagick commands. 100 | """ 101 | 102 | font_size = 157.41 * self.font_size 103 | interline_spacing = -22 + self.font_interline_spacing 104 | kerning = -1.25 * self.font_kerning 105 | 106 | return [ 107 | f'-font "{self.font_file}"', 108 | f'-kerning {kerning}', 109 | f'-interword-spacing 50', 110 | f'-interline-spacing {interline_spacing}', 111 | f'-pointsize {font_size}', 112 | f'-gravity south', 113 | ] 114 | 115 | 116 | def __title_text_black_stroke(self) -> ImageMagickCommands: 117 | """ 118 | ImageMagick commands to implement the title text's black stroke. 119 | 120 | Returns: 121 | List of ImageMagick commands. 122 | """ 123 | 124 | stroke_width = 1.0 * self.font_stroke_width 125 | 126 | return [ 127 | f'-fill black', 128 | f'-stroke black', 129 | f'-strokewidth {stroke_width}', 130 | ] 131 | 132 | 133 | def __series_count_text_global_effects(self) -> ImageMagickCommands: 134 | """ 135 | ImageMagick commands for global text effects applied to all series count 136 | text (season/episode count and dot). 137 | 138 | Returns: 139 | List of ImageMagick commands. 140 | """ 141 | 142 | return [ 143 | f'-kerning 5.42', 144 | f'-pointsize 67.75', 145 | ] 146 | 147 | 148 | def __series_count_text_black_stroke(self) -> ImageMagickCommands: 149 | """ 150 | ImageMagick commands for adding the necessary black stroke effects to 151 | series count text. 152 | 153 | Returns: 154 | List of ImageMagick commands. 155 | """ 156 | 157 | return [ 158 | f'-fill black', 159 | f'-stroke black', 160 | f'-strokewidth 6', 161 | ] 162 | 163 | 164 | def __series_count_text_effects(self) -> ImageMagickCommands: 165 | """ 166 | ImageMagick commands for adding the necessary text effects to the series 167 | count text. 168 | 169 | Returns: 170 | List of ImageMagick commands. 171 | """ 172 | 173 | return [ 174 | f'-fill "{self.SERIES_COUNT_TEXT_COLOR}"', 175 | f'-stroke "{self.SERIES_COUNT_TEXT_COLOR}"', 176 | f'-strokewidth 0.75', 177 | ] 178 | 179 | 180 | @property 181 | def title_text_command(self) -> ImageMagickCommands: 182 | """ 183 | Subcommand for adding title text to the source image. 184 | 185 | Returns: 186 | List of ImageMagick commands. 187 | """ 188 | 189 | vertical_shift = 100 + self.font_vertical_shift 190 | 191 | return [ 192 | *self.__title_text_global_effects(), 193 | *self.__title_text_black_stroke(), 194 | f'-annotate +0+{vertical_shift} "{self.title_text}"', 195 | f'-fill "{self.font_color}"', 196 | f'-annotate +0+{vertical_shift} "{self.title_text}"', 197 | ] 198 | 199 | 200 | @property 201 | def index_text_command(self) -> ImageMagickCommands: 202 | """ 203 | Subcommand for adding the index text to the source image. 204 | 205 | Returns: 206 | List of ImageMagick commands. 207 | """ 208 | 209 | if self.hide_season_text: 210 | return [ 211 | *self.__series_count_text_global_effects(), 212 | f'-font "{self.EPISODE_COUNT_FONT}"', 213 | f'-gravity center', 214 | *self.__series_count_text_black_stroke(), 215 | f'-annotate +0+697.2 "{self.episode_text}"', 216 | *self.__series_count_text_effects(), 217 | f'-annotate +0+697.2 "{self.episode_text}"', 218 | ] 219 | 220 | return [ 221 | f'-background transparent', 222 | f'+interword-spacing', 223 | f'-gravity south', 224 | f'\(', 225 | *self.__series_count_text_global_effects(), 226 | *self.__series_count_text_black_stroke(), 227 | f'-font "{self.SEASON_COUNT_FONT}"', 228 | f'label:"{self.season_text}"', 229 | f'label:"• "', 230 | f'-font "{self.EPISODE_COUNT_FONT}"', 231 | f'label:"{self.episode_text}"', 232 | f'+smush 15 \)', 233 | f'-geometry +0+35', 234 | f'-composite', 235 | 236 | f'\(', 237 | *self.__series_count_text_global_effects(), 238 | *self.__series_count_text_effects(), 239 | f'-font "{self.SEASON_COUNT_FONT}"', 240 | f'label:"{self.season_text}"', 241 | f'label:"• "', 242 | f'-font "{self.EPISODE_COUNT_FONT}"', 243 | f'label:"{self.episode_text}"', 244 | f'+smush 18 \)', 245 | f'-geometry +0+35', 246 | f'-composite', 247 | ] 248 | 249 | 250 | @staticmethod 251 | def is_custom_font(font: 'Font') -> bool: 252 | """ 253 | Determines whether the given font characteristics constitute a 254 | default or custom font. 255 | 256 | Args: 257 | font: The Font being evaluated. 258 | 259 | Returns: 260 | True if a custom font is indicated, False otherwise. 261 | """ 262 | 263 | return ((font.color != SlimTitleCard.TITLE_COLOR) 264 | or (font.file != SlimTitleCard.TITLE_FONT) 265 | or (font.interline_spacing != 0) 266 | or (font.kerning != 1.0) 267 | or (font.size != 1.0) 268 | or (font.stroke_width != 1.0) 269 | or (font.vertical_shift != 0) 270 | ) 271 | 272 | 273 | @staticmethod 274 | def is_custom_season_titles( 275 | custom_episode_map: bool, episode_text_format: str) -> bool: 276 | """ 277 | Determines whether the given attributes constitute custom or 278 | generic season titles. 279 | 280 | Args: 281 | custom_episode_map: Whether the EpisodeMap was customized. 282 | episode_text_format: The episode text format in use. 283 | 284 | Returns: 285 | True if custom season titles are indicated, False otherwise. 286 | """ 287 | 288 | # Nonstandard episode text format 289 | if episode_text_format != 'EPISODE {episode_number}': 290 | return True 291 | 292 | return custom_episode_map 293 | 294 | 295 | def create(self) -> None: 296 | """ 297 | Make the necessary ImageMagick and system calls to create this 298 | object's defined title card. 299 | """ 300 | 301 | command = ' '.join([ 302 | f'convert "{self.source_file.resolve()}"', 303 | *self.resize_and_style, 304 | # Add gradient 305 | f'"{self.__GRADIENT_IMAGE.resolve()}"', 306 | f'-composite', 307 | # Add title and index text 308 | *self.title_text_command, 309 | *self.index_text_command, 310 | # Create card 311 | *self.resize_output, 312 | f'"{self.output_file.resolve()}"', 313 | ]) 314 | 315 | self.image_magick.run(command) -------------------------------------------------------------------------------- /Beedman/GradientLogoTitleCard.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from re import findall 3 | from typing import Optional 4 | 5 | from modules.BaseCardType import BaseCardType, ImageMagickCommands 6 | from modules.Debug import log 7 | from modules.RemoteFile import RemoteFile 8 | 9 | class GradientLogoTitleCard(BaseCardType): 10 | """ 11 | This class describes a type of CardType created by Beedman, and is 12 | a modification of the StandardTitleCard class with a different 13 | gradient overlay, featuring a logo and left-aligned title text 14 | """ 15 | 16 | """Directory where all reference files used by this card are stored""" 17 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' 18 | 19 | """Characteristics for title splitting by this class""" 20 | TITLE_CHARACTERISTICS = { 21 | 'max_line_width': 32, # Character count to begin splitting titles 22 | 'max_line_count': 3, # Maximum number of lines a title can take up 23 | 'top_heavy': False, # This class uses bottom heavy titling 24 | } 25 | 26 | """Default font and text color for episode title text""" 27 | TITLE_FONT = str((REF_DIRECTORY / 'Sequel-Neue.otf').resolve()) 28 | TITLE_COLOR = '#EBEBEB' 29 | 30 | """Default characters to replace in the generic font""" 31 | FONT_REPLACEMENTS = { 32 | '[': '(', ']': ')', '(': '[', ')': ']', '―': '-', '…': '...' 33 | } 34 | 35 | """Whether this CardType uses season titles for archival purposes""" 36 | USES_SEASON_TITLE = True 37 | 38 | """Archive name for this card type""" 39 | ARCHIVE_NAME = 'Gradient Logo Style' 40 | 41 | """Source path for the gradient image overlayed over all title cards""" 42 | __GRADIENT_IMAGE = str(RemoteFile('Beedman', 'leftgradient.png')) 43 | 44 | """Default fonts and color for series count text""" 45 | SEASON_COUNT_FONT = REF_DIRECTORY / 'Proxima Nova Semibold.otf' 46 | EPISODE_COUNT_FONT = REF_DIRECTORY / 'Proxima Nova Regular.otf' 47 | SERIES_COUNT_TEXT_COLOR = '#CFCFCF' 48 | 49 | __slots__ = ( 50 | 'source_file', 'output_file', 'title_text', 'season_text', 51 | 'episode_text', 'hide_season_text', 'font_color', 'font_file', 52 | 'font_interline_spacing', 'font_kerning', 'font_size', 53 | 'font_stroke_width', 'font_vertical_shift', 'logo', 54 | ) 55 | 56 | 57 | def __init__(self, *, 58 | source_file: Path, 59 | card_file: Path, 60 | title_text: str, 61 | season_text: str, 62 | episode_text: str, 63 | hide_season_text: bool = False, 64 | font_color: str = TITLE_COLOR, 65 | font_file: str = TITLE_FONT, 66 | font_interline_spacing: int = 0, 67 | font_kerning: float = 1.0, 68 | font_size: float = 1.0, 69 | font_stroke_width: float = 1.0, 70 | font_vertical_shift: int = 0, 71 | blur: bool = False, 72 | grayscale: bool = False, 73 | logo: Optional[str] = None, 74 | **unused) -> None: 75 | """ 76 | Construct a new instance of this card. 77 | """ 78 | 79 | # Initialize the parent class - this sets up an ImageMagickInterface 80 | super().__init__(blur, grayscale) 81 | 82 | self.source_file = source_file 83 | self.output_file = card_file 84 | self.logo = Path(logo) if logo is not None else None 85 | 86 | # Ensure characters that need to be escaped are 87 | self.title_text = self.image_magick.escape_chars(title_text) 88 | self.season_text = self.image_magick.escape_chars(season_text.upper()) 89 | self.episode_text = self.image_magick.escape_chars(episode_text.upper()) 90 | self.hide_season_text = hide_season_text or len(season_text) == 0 91 | 92 | self.font_color = font_color 93 | self.font_file = font_file 94 | self.font_interline_spacing = font_interline_spacing 95 | self.font_kerning = font_kerning 96 | self.font_size = font_size 97 | self.font_stroke_width = font_stroke_width 98 | self.font_vertical_shift = font_vertical_shift 99 | 100 | 101 | @property 102 | def logo_command(self) -> ImageMagickCommands: 103 | """ 104 | Get the ImageMagick commands to add the resized logo to the 105 | source image. 106 | 107 | Returns: 108 | List of ImageMagick commands. 109 | """ 110 | 111 | return [ 112 | # Resize logo 113 | f'\( "{self.logo.resolve()}"', 114 | f'-trim', 115 | f'+repage', 116 | f'-resize x650', 117 | f'-resize 1155x650\> \)', 118 | # Overlay resized logo 119 | f'-gravity northwest', 120 | f'-define colorspace:auto-grayscale=false', 121 | f'-type TrueColorAlpha', 122 | f'-geometry "+50+50"', 123 | f'-composite', 124 | ] 125 | 126 | 127 | @property 128 | def title_text_command(self) -> ImageMagickCommands: 129 | """ 130 | ImageMagick commands to implement the title text's global 131 | effects. Specifically the the font, kerning, fontsize, and 132 | center gravity. 133 | 134 | Returns: 135 | List of ImageMagick commands. 136 | """ 137 | 138 | font_size = 157.41 * self.font_size 139 | interline_spacing = -22 + self.font_interline_spacing 140 | kerning = -1.25 * self.font_kerning 141 | stroke_width = 3.0 * self.font_stroke_width 142 | vertical_shift = 125 + self.font_vertical_shift 143 | 144 | return [ 145 | f'-font "{self.font_file}"', 146 | f'-kerning {kerning}', 147 | f'-interword-spacing 50', 148 | f'-interline-spacing {interline_spacing}', 149 | f'-pointsize {font_size}', 150 | f'-gravity southwest', 151 | f'-fill black', 152 | f'-stroke black', 153 | f'-strokewidth {stroke_width}', 154 | f'-annotate +50+{vertical_shift} "{self.title_text}"', 155 | f'-fill "{self.font_color}"', 156 | f'-annotate +50+{vertical_shift} "{self.title_text}"', 157 | ] 158 | 159 | 160 | @property 161 | def index_text_command(self) -> ImageMagickCommands: 162 | """ 163 | Get the ImageMagick commands required to add the index (season 164 | and episode) text to the image. 165 | 166 | Returns: 167 | List of ImageMagick commands. 168 | """ 169 | 170 | # Season hiding, just add episode text 171 | if self.hide_season_text: 172 | return [ 173 | f'-kerning 5.42', 174 | f'-pointsize 67.75', 175 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 176 | f'-gravity southwest', 177 | f'-fill black', 178 | f'-stroke black', 179 | f'-strokewidth 6', 180 | f'-annotate +50+50 "{self.episode_text}"', 181 | f'-fill "{self.SERIES_COUNT_TEXT_COLOR}"', 182 | f'-stroke "{self.SERIES_COUNT_TEXT_COLOR}"', 183 | f'-strokewidth 0.75', 184 | f'-annotate +50+50 "{self.episode_text}"', 185 | ] 186 | 187 | return [ 188 | f'-background transparent', 189 | f'+interword-spacing', 190 | f'-kerning 5.42', 191 | f'-pointsize 67.75', 192 | f'-fill black', 193 | f'-stroke black', 194 | f'-strokewidth 6', 195 | f'\( -gravity center', 196 | f'-font "{self.SEASON_COUNT_FONT.resolve()}"', 197 | f'label:"{self.season_text} •"', 198 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 199 | f'label:"{self.episode_text}"', 200 | f'+smush 30 \)', 201 | f'-gravity southwest', 202 | f'-geometry +50+50', 203 | f'-composite', 204 | f'-fill "{self.SERIES_COUNT_TEXT_COLOR}"', 205 | f'-stroke "{self.SERIES_COUNT_TEXT_COLOR}"', 206 | f'-strokewidth 0.75', 207 | f'\( -gravity center', 208 | f'-font "{self.SEASON_COUNT_FONT.resolve()}"', 209 | f'label:"{self.season_text} •"', 210 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 211 | f'label:"{self.episode_text}"', 212 | f'+smush 30 \)', 213 | f'-gravity southwest', 214 | f'-geometry +50+50', 215 | f'-composite', 216 | ] 217 | 218 | 219 | @staticmethod 220 | def is_custom_font(font: 'Font') -> bool: 221 | """ 222 | Determines whether the given arguments represent a custom font 223 | for this card. 224 | 225 | Args: 226 | font: The Font being evaluated. 227 | 228 | Returns: 229 | True if a custom font is indicated, False otherwise. 230 | """ 231 | 232 | return ((font.color != GradientLogoTitleCard.TITLE_COLOR) 233 | or (font.file != GradientLogoTitleCard.TITLE_FONT) 234 | or (font.interline_spacing != 0) 235 | or (font.kerning != 1.0) 236 | or (font.size != 1.0) 237 | or (font.stroke_width != 1.0) 238 | or (font.vertical_shift != 0) 239 | ) 240 | 241 | 242 | @staticmethod 243 | def is_custom_season_titles( 244 | custom_episode_map: bool, episode_text_format: str) -> bool: 245 | """ 246 | Determines whether the given attributes constitute custom or 247 | generic season titles. 248 | 249 | Args: 250 | custom_episode_map: Whether the EpisodeMap was customized. 251 | episode_text_format: The episode text format in use. 252 | 253 | Returns: 254 | True if custom season titles are indicated, False otherwise. 255 | """ 256 | 257 | standard_etf = GradientLogoTitleCard.EPISODE_TEXT_FORMAT.upper() 258 | 259 | return (custom_episode_map 260 | or episode_text_format.upper() != standard_etf) 261 | 262 | 263 | def create(self) -> None: 264 | """ 265 | Make the necessary ImageMagick and system calls to create this 266 | object's defined title card. 267 | """ 268 | 269 | # Skip card if logo doesn't exist 270 | if self.logo is None: 271 | log.error(f'Logo file not specified') 272 | return None 273 | elif not self.logo.exists(): 274 | log.error(f'Logo file "{self.logo.resolve()}" does not exist') 275 | return None 276 | 277 | command = ' '.join([ 278 | f'convert', 279 | # Resize source image 280 | f'"{self.source_file.resolve()}"', 281 | *self.resize_and_style, 282 | # Overlay gradient 283 | f'"{self.__GRADIENT_IMAGE}"', 284 | f'-composite', 285 | # Overlay resized logo 286 | *self.logo_command, 287 | # Put title text 288 | *self.title_text_command, 289 | # Put season/episode text 290 | *self.index_text_command, 291 | # Create and resize output 292 | *self.resize_output, 293 | f'"{self.output_file.resolve()}"', 294 | ]) 295 | 296 | self.image_magick.run(command) -------------------------------------------------------------------------------- /Wdvh/WhiteTextStandard.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from modules.BaseCardType import BaseCardType 4 | from modules.RemoteFile import RemoteFile 5 | 6 | class WhiteTextStandard(BaseCardType): 7 | """ 8 | WDVH's WhiteTextStandard card type. 9 | """ 10 | 11 | """Directory where all reference files used by this card are stored""" 12 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' 13 | 14 | """Characteristics for title splitting by this class""" 15 | TITLE_CHARACTERISTICS = { 16 | 'max_line_width': 32, # Character count to begin splitting titles 17 | 'max_line_count': 3, # Maximum number of lines a title can take up 18 | 'top_heavy': False, # This class uses bottom heavy titling 19 | } 20 | 21 | """Default font and text color for episode title text""" 22 | TITLE_FONT = str(RemoteFile('Wdvh', 'TerminalDosis-Bold.ttf')) 23 | TITLE_COLOR = '#FFFFFF' 24 | 25 | """Default characters to replace in the generic font""" 26 | FONT_REPLACEMENTS = {} 27 | 28 | """Whether this CardType uses season titles for archival purposes""" 29 | USES_SEASON_TITLE = True 30 | 31 | """Standard class has standard archive name""" 32 | ARCHIVE_NAME = 'White Text Standard Style' 33 | 34 | """Source path for the gradient image overlayed over all title cards""" 35 | __GRADIENT_IMAGE = REF_DIRECTORY / 'GRADIENT.png' 36 | 37 | """Default fonts and color for series count text""" 38 | SEASON_COUNT_FONT = REF_DIRECTORY / 'Sequel-Neue.otf' 39 | EPISODE_COUNT_FONT = REF_DIRECTORY / 'Sequel-Neue.otf' 40 | SERIES_COUNT_TEXT_COLOR = '#FFFFFF' 41 | 42 | """Paths to intermediate files that are deleted after the card is created""" 43 | __SOURCE_WITH_GRADIENT = BaseCardType.TEMP_DIR / 'source_gradient.png' 44 | __GRADIENT_WITH_TITLE = BaseCardType.TEMP_DIR / 'gradient_title.png' 45 | 46 | __slots__ = ( 47 | 'source_file', 'output_file', 'title', 'season_text', 'episode_text', 48 | 'font', 'font_size', 'title_color', 'hide_season', 'separator', 49 | 'vertical_shift', 'interline_spacing', 'kerning', 'stroke_width' 50 | ) 51 | 52 | 53 | def __init__(self, 54 | source_file: Path, 55 | card_file: Path, 56 | title_text: str, 57 | season_text: str, 58 | episode_text: str, 59 | hide_season_text: bool = False, 60 | font_color: str = TITLE_COLOR, 61 | font_file: str = TITLE_FONT, 62 | font_interline_spacing: int = 0, 63 | font_kerning: float = 1.0, 64 | font_size: float = 1.0, 65 | font_stroke_width: float = 1.0, 66 | font_vertical_shift: int = 0, 67 | blur: bool = False, 68 | grayscale: bool = False, 69 | separator: str = '-', 70 | **unused) -> None: 71 | """ 72 | Initialize this CardType object. 73 | """ 74 | 75 | # Initialize the parent class - this sets up an ImageMagickInterface 76 | super().__init__(blur, grayscale) 77 | 78 | self.source_file = source_file 79 | self.output_file = card_file 80 | 81 | # Ensure characters that need to be escaped are 82 | self.title = self.image_magick.escape_chars(title_text) 83 | self.season_text = self.image_magick.escape_chars(season_text.upper()) 84 | self.episode_text = self.image_magick.escape_chars(episode_text.upper()) 85 | self.hide_season = hide_season_text 86 | 87 | self.font = font_file 88 | self.font_size = font_size 89 | self.title_color = font_color 90 | self.vertical_shift = font_vertical_shift 91 | self.interline_spacing = font_interline_spacing 92 | self.kerning = font_kerning 93 | self.stroke_width = font_stroke_width 94 | 95 | self.separator = separator 96 | 97 | 98 | def __title_text_global_effects(self) -> list[str]: 99 | """ 100 | ImageMagick commands to implement the title text's global effects. 101 | Specifically the the font, kerning, fontsize, and center gravity. 102 | 103 | Returns: 104 | List of ImageMagick commands. 105 | """ 106 | 107 | font_size = 180 * self.font_size 108 | interline_spacing = -70 + self.interline_spacing 109 | kerning = -1.25 * self.kerning 110 | 111 | return [ 112 | f'-font "{self.font}"', 113 | f'-kerning {kerning}', 114 | f'-interword-spacing 50', 115 | f'-interline-spacing {interline_spacing}', 116 | f'-pointsize {font_size}', 117 | f'-gravity south', 118 | ] 119 | 120 | 121 | def __title_text_black_stroke(self) -> list[str]: 122 | """ 123 | ImageMagick commands to implement the title text's black stroke. 124 | 125 | Returns: 126 | List of ImageMagick commands. 127 | """ 128 | 129 | stroke_width = 4.0 * self.stroke_width 130 | 131 | return [ 132 | f'-fill white', 133 | f'-stroke "#062A40"', 134 | f'-strokewidth {stroke_width}', 135 | ] 136 | 137 | 138 | def __series_count_text_global_effects(self) -> list[str]: 139 | """ 140 | ImageMagick commands for global text effects applied to all series count 141 | text (season/episode count and dot). 142 | 143 | Returns: 144 | List of ImageMagick commands. 145 | """ 146 | 147 | return [ 148 | f'-kerning 5.42', 149 | f'-pointsize 85', 150 | ] 151 | 152 | 153 | def __series_count_text_black_stroke(self) -> list[str]: 154 | """ 155 | ImageMagick commands for adding the necessary black stroke effects to 156 | series count text. 157 | 158 | Returns: 159 | List of ImageMagick commands. 160 | """ 161 | 162 | return [ 163 | f'-fill white', 164 | f'-stroke "#062A40"', 165 | f'-strokewidth 2', 166 | ] 167 | 168 | 169 | def __series_count_text_effects(self) -> list[str]: 170 | """ 171 | ImageMagick commands for adding the necessary text effects to the series 172 | count text. 173 | 174 | Returns: 175 | List of ImageMagick commands. 176 | """ 177 | 178 | return [ 179 | f'-fill white', 180 | f'-stroke "#062A40"', 181 | f'-strokewidth 2', 182 | ] 183 | 184 | 185 | def _add_gradient(self) -> Path: 186 | """ 187 | Add the static gradient to this object's source image. 188 | 189 | Returns: 190 | Path to the created image. 191 | """ 192 | 193 | command = ' '.join([ 194 | f'convert "{self.source_file.resolve()}"', 195 | *self.resize_and_style, 196 | f'"{self.__GRADIENT_IMAGE.resolve()}"', 197 | f'-background None', 198 | f'-layers Flatten', 199 | f'"{self.__SOURCE_WITH_GRADIENT.resolve()}"', 200 | ]) 201 | 202 | self.image_magick.run(command) 203 | 204 | return self.__SOURCE_WITH_GRADIENT 205 | 206 | 207 | def _add_title_text(self, gradient_image: Path) -> Path: 208 | """ 209 | Adds episode title text to the provide image. 210 | 211 | :param gradient_image: The image with gradient added. 212 | 213 | :returns: Path to the created image that has a gradient and the title 214 | text added. 215 | """ 216 | 217 | vertical_shift = 145 + self.vertical_shift 218 | 219 | command = ' '.join([ 220 | f'convert "{gradient_image.resolve()}"', 221 | *self.__title_text_global_effects(), 222 | *self.__title_text_black_stroke(), 223 | f'-annotate +0+{vertical_shift} "{self.title}"', 224 | f'-fill "{self.title_color}"', 225 | f'-annotate +0+{vertical_shift} "{self.title}"', 226 | f'"{self.__GRADIENT_WITH_TITLE.resolve()}"', 227 | ]) 228 | 229 | self.image_magick.run(command) 230 | 231 | return self.__GRADIENT_WITH_TITLE 232 | 233 | 234 | def _add_series_count_text(self, titled_image: Path) -> Path: 235 | """ 236 | Adds the (optional) season and episode text. 237 | 238 | :param titled_image: The titled image to add text to. 239 | 240 | :returns: Path to the created image (the output file). 241 | """ 242 | 243 | if self.hide_season: 244 | series_count_text = self.episode_text 245 | else: 246 | series_count_text = (f'{self.season_text} {self.separator} ' 247 | f'{self.episode_text}') 248 | 249 | command = ' '.join([ 250 | f'convert "{titled_image.resolve()}"', 251 | *self.__series_count_text_global_effects(), 252 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 253 | f'-gravity center', 254 | *self.__series_count_text_black_stroke(), 255 | f'-annotate +0+800 "{series_count_text}"', 256 | *self.__series_count_text_effects(), 257 | f'-annotate +0+800 "{series_count_text}"', 258 | *self.resize_output, 259 | f'"{self.output_file.resolve()}"', 260 | ]) 261 | 262 | self.image_magick.run(command) 263 | 264 | return self.output_file 265 | 266 | 267 | @staticmethod 268 | def is_custom_font(font: 'Font') -> bool: 269 | """ 270 | Determines whether the given font characteristics constitute a 271 | default or custom font. 272 | 273 | Args: 274 | font: The Font being evaluated. 275 | 276 | Returns: 277 | True if a custom font is indicated, False otherwise. 278 | """ 279 | 280 | return ((font.file != WhiteTextStandard.TITLE_FONT) 281 | or (font.size != 1.0) 282 | or (font.color != WhiteTextStandard.TITLE_COLOR) 283 | or (font.replacements != WhiteTextStandard.FONT_REPLACEMENTS) 284 | or (font.vertical_shift != 0) 285 | or (font.interline_spacing != 0) 286 | or (font.kerning != 1.0) 287 | or (font.stroke_width != 1.0)) 288 | 289 | 290 | @staticmethod 291 | def is_custom_season_titles( 292 | custom_episode_map: bool, episode_text_format: str) -> bool: 293 | """ 294 | Determines whether the given attributes constitute custom or 295 | generic season titles. 296 | 297 | Args: 298 | custom_episode_map: Whether the EpisodeMap was customized. 299 | episode_text_format: The episode text format in use. 300 | 301 | Returns: 302 | True if custom season title are indicated. False otherwise. 303 | """ 304 | 305 | standard_etf = WhiteTextStandard.EPISODE_TEXT_FORMAT.upper() 306 | 307 | return (custom_episode_map 308 | or episode_text_format.upper() != standard_etf) 309 | 310 | 311 | def create(self) -> None: 312 | """ 313 | Make the necessary ImageMagick and system calls to create this object's 314 | defined title card. 315 | """ 316 | 317 | # Add the gradient to the source image (always) 318 | gradient_image = self._add_gradient() 319 | 320 | # Add either one or two lines of episode text 321 | titled_image = self._add_title_text(gradient_image) 322 | 323 | # Add season/episode text 324 | self._add_series_count_text(titled_image) 325 | 326 | # Delete all intermediate images 327 | self.image_magick.delete_intermediate_images(gradient_image, titled_image) -------------------------------------------------------------------------------- /Yozora/ref/slim/LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Wdvh/WhiteTextAbsoluteLogo.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | from modules.BaseCardType import BaseCardType 5 | from modules.Debug import log 6 | from modules.RemoteFile import RemoteFile 7 | 8 | class WhiteTextAbsoluteLogo(BaseCardType): 9 | """ 10 | This class describes Wdvh's absolute CardType intended for absolute 11 | episode ordering. 12 | """ 13 | 14 | """Directory where all reference files used by this card are stored""" 15 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' 16 | 17 | """Characteristics for title splitting by this class""" 18 | TITLE_CHARACTERISTICS = { 19 | 'max_line_width': 32, # Character count to begin splitting titles 20 | 'max_line_count': 3, # Maximum number of lines a title can take up 21 | 'top_heavy': False, # This class uses bottom heavy titling 22 | } 23 | 24 | """Default font and text color for episode title text""" 25 | TITLE_FONT = str(RemoteFile('Wdvh', 'TerminalDosis-Bold.ttf')) 26 | TITLE_COLOR = '#FFFFFF' 27 | 28 | """Default characters to replace in the generic font""" 29 | FONT_REPLACEMENTS = {} 30 | 31 | """Whether this CardType uses season titles for archival purposes""" 32 | USES_SEASON_TITLE = False 33 | 34 | """Whether this CardType uses unique source images""" 35 | USES_UNIQUE_SOURCES = False 36 | 37 | """Standard class has standard archive name""" 38 | ARCHIVE_NAME = 'White Text Absolute Ordering Logo Style' 39 | 40 | EPISODE_TEXT_FORMAT = "E{abs_number:02}" 41 | 42 | """Default fonts and color for series count text""" 43 | SEASON_COUNT_FONT = REF_DIRECTORY / 'Sequel-Neue.otf' 44 | EPISODE_COUNT_FONT = REF_DIRECTORY / 'Sequel-Neue.otf' 45 | SERIES_COUNT_TEXT_COLOR = '#FFFFFF' 46 | 47 | """Paths to intermediate files that are deleted after the card is created""" 48 | __RESIZED_LOGO = BaseCardType.TEMP_DIR / 'resized_logo.png' 49 | __BACKDROP_WITH_LOGO = BaseCardType.TEMP_DIR / 'backdrop_logo.png' 50 | __LOGO_WITH_TITLE = BaseCardType.TEMP_DIR / 'logo_title.png' 51 | 52 | __slots__ = ( 53 | 'logo', 'output_file', 'title', 'episode_text', 'font', 'font_size', 54 | 'title_color', 'vertical_shift', 'interline_spacing', 'kerning', 55 | 'stroke_width', 'background' 56 | ) 57 | 58 | 59 | def __init__(self, *, 60 | card_file: Path, 61 | title_text: str, 62 | episode_text: str, 63 | font_color: str = TITLE_COLOR, 64 | font_file: str = TITLE_FONT, 65 | font_interline_spacing: int = 0, 66 | font_kerning: float = 1.0, 67 | font_size: float = 1.0, 68 | font_stroke_width: float = 1.0, 69 | font_vertical_shift: int = 0, 70 | blur: bool = False, 71 | grayscale: bool = False, 72 | logo: Optional[str] = None, 73 | background: str = '#000000', 74 | **unused) -> None: 75 | """ 76 | Initialize this CardType object. 77 | """ 78 | 79 | # Initialize the parent class - this sets up an ImageMagickInterface 80 | super().__init__(blur, grayscale) 81 | 82 | # Look for logo if it's a format string 83 | if isinstance(logo, str): 84 | self.logo = Path(logo) 85 | else: 86 | self.logo = None 87 | 88 | self.output_file = card_file 89 | 90 | # Ensure characters that need to be escaped are 91 | self.title = self.image_magick.escape_chars(title_text) 92 | self.episode_text = self.image_magick.escape_chars(episode_text.upper()) 93 | 94 | self.font = font_file 95 | self.title_color = font_color 96 | self.interline_spacing = font_interline_spacing 97 | self.kerning = font_kerning 98 | self.font_size = font_size 99 | self.stroke_width = font_stroke_width 100 | self.vertical_shift = font_vertical_shift 101 | 102 | self.background = background 103 | 104 | 105 | def __title_text_global_effects(self) -> list[str]: 106 | """ 107 | ImageMagick commands to implement the title text's global effects. 108 | Specifically the the font, kerning, fontsize, and center gravity. 109 | 110 | Returns: 111 | List of ImageMagick commands. 112 | """ 113 | 114 | font_size = 180 * self.font_size 115 | interline_spacing = -17 + self.interline_spacing 116 | kerning = -1.25 * self.kerning 117 | 118 | return [ 119 | f'-font "{self.font}"', 120 | f'-kerning {kerning}', 121 | f'-interword-spacing 50', 122 | f'-interline-spacing {interline_spacing}', 123 | f'-pointsize {font_size}', 124 | f'-gravity south', 125 | ] 126 | 127 | 128 | def __title_text_black_stroke(self) -> list[str]: 129 | """ 130 | ImageMagick commands to implement the title text's black stroke. 131 | 132 | Returns: 133 | List of ImageMagick commands. 134 | """ 135 | 136 | stroke_width = 4.0 * self.stroke_width 137 | 138 | return [ 139 | f'-fill white', 140 | f'-stroke "#062A40"', 141 | f'-strokewidth {stroke_width}', 142 | ] 143 | 144 | 145 | def __series_count_text_global_effects(self) -> list[str]: 146 | """ 147 | ImageMagick commands for global text effects applied to all series count 148 | text (season/episode count and dot). 149 | 150 | Returns: 151 | List of ImageMagick commands. 152 | """ 153 | 154 | return [ 155 | f'-kerning 5.42', 156 | f'-pointsize 120', 157 | ] 158 | 159 | 160 | def __series_count_text_black_stroke(self) -> list[str]: 161 | """ 162 | ImageMagick commands for adding the necessary black stroke effects to 163 | series count text. 164 | 165 | Returns: 166 | List of ImageMagick commands. 167 | """ 168 | 169 | return [ 170 | f'-fill white', 171 | f'-stroke "#062A40"', 172 | f'-strokewidth 2', 173 | ] 174 | 175 | 176 | def __series_count_text_effects(self) -> list[str]: 177 | """ 178 | ImageMagick commands for adding the necessary text effects to the series 179 | count text. 180 | 181 | Returns: 182 | List of ImageMagick commands. 183 | """ 184 | 185 | return [ 186 | f'-fill white', 187 | f'-stroke "#062A40"', 188 | f'-strokewidth 2', 189 | ] 190 | 191 | 192 | def _resize_logo(self) -> Path: 193 | """ 194 | Resize the logo into at most a 1875x1030 bounding box. 195 | 196 | Returns: 197 | Path to the created image. 198 | """ 199 | 200 | command = ' '.join([ 201 | f'convert', 202 | f'"{self.logo.resolve()}"', 203 | f'-resize x1030', 204 | f'-resize 1875x1030\>', 205 | f'"{self.__RESIZED_LOGO.resolve()}"', 206 | ]) 207 | 208 | self.image_magick.run(command) 209 | 210 | return self.__RESIZED_LOGO 211 | 212 | 213 | def _add_logo_to_backdrop(self, resized_logo: Path) -> Path: 214 | """ 215 | Add the resized logo to a fixed color backdrop. 216 | 217 | Returns: 218 | Path to the created image. 219 | """ 220 | 221 | # Get height of the resized logo to determine offset 222 | height_command = ' '.join([ 223 | f'identify', 224 | f'-format "%h"', 225 | f'"{resized_logo.resolve()}"', 226 | ]) 227 | 228 | height = int(self.image_magick.run_get_output(height_command)) 229 | 230 | # Get offset of where to place logo onto card 231 | offset = 60 + ((1030 - height) // 2) 232 | 233 | command = ' '.join([ 234 | f'convert', 235 | f'-size "{self.TITLE_CARD_SIZE}"', # Create backdrop 236 | f'xc:"{self.background}"', # Fill canvas with color 237 | f'"{resized_logo.resolve()}"', 238 | f'-set colorspace sRGB', 239 | f'-gravity north', 240 | f'-geometry "+0+{offset}"', # Put logo on backdrop 241 | f'-composite "{self.__BACKDROP_WITH_LOGO.resolve()}"', 242 | ]) 243 | 244 | self.image_magick.run(command) 245 | 246 | return self.__BACKDROP_WITH_LOGO 247 | 248 | 249 | def _add_title_text(self, backdrop_logo: Path) -> Path: 250 | """ 251 | Adds episode title text to the provide image. 252 | 253 | :param backdrop_logo: The backdrop and logo image. 254 | 255 | :returns: Path to the created image that has the title text added. 256 | """ 257 | 258 | vertical_shift = 245 + self.vertical_shift 259 | 260 | command = ' '.join([ 261 | f'convert "{backdrop_logo.resolve()}"', 262 | *self.resize_and_style, 263 | *self.__title_text_global_effects(), 264 | *self.__title_text_black_stroke(), 265 | f'-annotate +0+{vertical_shift} "{self.title}"', 266 | f'-fill "{self.title_color}"', 267 | f'-annotate +0+{vertical_shift} "{self.title}"', 268 | f'"{self.__LOGO_WITH_TITLE.resolve()}"', 269 | ]) 270 | 271 | self.image_magick.run(command) 272 | 273 | return self.__LOGO_WITH_TITLE 274 | 275 | 276 | def _add_series_count_text_no_season(self, titled_image: Path) -> Path: 277 | """ 278 | Adds the series count text without season title/number. 279 | 280 | :param titled_image: The titled image to add text to. 281 | 282 | :returns: Path to the created image (the output file). 283 | """ 284 | 285 | command = ' '.join([ 286 | f'convert "{titled_image.resolve()}"', 287 | *self.__series_count_text_global_effects(), 288 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 289 | f'-gravity west', 290 | *self.__series_count_text_black_stroke(), 291 | f'-annotate +100-750 "{self.episode_text}"', 292 | *self.__series_count_text_effects(), 293 | f'-annotate +100-750 "{self.episode_text}"', 294 | *self.resize_output, 295 | f'"{self.output_file.resolve()}"', 296 | ]) 297 | 298 | self.image_magick.run(command) 299 | 300 | return self.output_file 301 | 302 | 303 | @staticmethod 304 | def is_custom_font(font: 'Font') -> bool: 305 | """ 306 | Determines whether the given font characteristics constitute a 307 | default or custom font. 308 | 309 | Args: 310 | font: The Font being evaluated. 311 | 312 | Returns: 313 | False, as custom fonts are not used. 314 | """ 315 | 316 | return ((font.file != WhiteTextAbsoluteLogo.TITLE_FONT) 317 | or (font.size != 1.0) 318 | or (font.color != WhiteTextAbsoluteLogo.TITLE_COLOR) 319 | or (font.replacements != WhiteTextAbsoluteLogo.FONT_REPLACEMENTS) 320 | or (font.vertical_shift != 0) 321 | or (font.interline_spacing != 0) 322 | or (font.kerning != 1.0) 323 | or (font.stroke_width != 1.0)) 324 | 325 | 326 | @staticmethod 327 | def is_custom_season_titles( 328 | custom_episode_map: bool, episode_text_format: str) -> bool: 329 | """ 330 | Determines whether the given attributes constitute custom or 331 | generic season titles. 332 | 333 | Args: 334 | custom_episode_map: Whether the EpisodeMap was customized. 335 | episode_text_format: The episode text format in use. 336 | 337 | Returns: 338 | False. Custom season titles are not used. 339 | """ 340 | 341 | return False 342 | 343 | 344 | def create(self) -> None: 345 | """ 346 | Make the necessary ImageMagick and system calls to create this 347 | object's defined title card. 348 | """ 349 | 350 | # Skip card if logo doesn't exist 351 | if self.logo is None: 352 | log.error(f'Logo file not specified') 353 | return None 354 | elif not self.logo.exists(): 355 | log.error(f'Logo file "{self.logo.resolve()}" does not exist') 356 | return None 357 | 358 | # Resize logo 359 | resized_logo = self._resize_logo() 360 | 361 | # Create backdrop+logo image 362 | backdrop_logo = self._add_logo_to_backdrop(resized_logo) 363 | 364 | # Add either one or two lines of episode text 365 | titled_image = self._add_title_text(backdrop_logo) 366 | 367 | # Add episode text 368 | self._add_series_count_text_no_season(titled_image) 369 | 370 | # Delete all intermediate images 371 | self.image_magick.delete_intermediate_images(backdrop_logo, titled_image) -------------------------------------------------------------------------------- /azuravian/TitleColorMatch.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from re import findall, compile as re_compile 3 | from typing import Optional 4 | 5 | from modules.BaseCardType import BaseCardType, ImageMagickCommands 6 | from modules.CleanPath import CleanPath 7 | from modules.Debug import log 8 | from modules.RemoteFile import RemoteFile 9 | 10 | class TitleColorMatch(BaseCardType): 11 | """ 12 | This class describes a type of CardType created by azuravian, and is 13 | a modification of Beedman's GradientLogoTitleCard class with a few 14 | changes, specifically the ability to autoselect a font color that 15 | matches the logo, as well as trimming the logo of any extra 16 | transparent space that makes its location incorrect. 17 | """ 18 | 19 | """Directory where all reference files used by this card are stored""" 20 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' 21 | 22 | """Characteristics for title splitting by this class""" 23 | TITLE_CHARACTERISTICS = { 24 | 'max_line_width': 32, # Character count to begin splitting titles 25 | 'max_line_count': 3, # Maximum number of lines a title can take up 26 | 'top_heavy': False, # This class uses bottom heavy titling 27 | } 28 | 29 | """Default font and text color for episode title text""" 30 | TITLE_FONT = str((REF_DIRECTORY / 'Sequel-Neue.otf').resolve()) 31 | TITLE_COLOR = '#EBEBEB' 32 | 33 | """Default characters to replace in the generic font""" 34 | FONT_REPLACEMENTS = { 35 | '[': '(', ']': ')', '(': '[', ')': ']', '―': '-', '…': '...' 36 | } 37 | 38 | """Whether this CardType uses season titles for archival purposes""" 39 | USES_SEASON_TITLE = True 40 | 41 | """Archive name for this card type""" 42 | ARCHIVE_NAME = 'Title Color Match Style' 43 | 44 | """Source path for the gradient image overlayed over all title cards""" 45 | __GRADIENT_IMAGE = str(RemoteFile('azuravian', 'leftgradient.png')) 46 | 47 | """Default fonts and color for series count text""" 48 | SEASON_COUNT_FONT = REF_DIRECTORY / 'Proxima Nova Semibold.otf' 49 | EPISODE_COUNT_FONT = REF_DIRECTORY / 'Proxima Nova Regular.otf' 50 | SERIES_COUNT_TEXT_COLOR = '#CFCFCF' 51 | 52 | """Regex to match colors/counts in ImageMagick histograms""" 53 | __COLORDATA_REGEX = re_compile(r'[\s]*(\d*)?:\s.*\s(#\w{8}).*\n?') 54 | 55 | __slots__ = ( 56 | 'source_file', 'output_file', 'title_text', 'season_text', 57 | 'episode_text', 'hide_season_text', 'font_color', 'font_file', 58 | 'font_interline_spacing', 'font_kerning', 'font_size', 59 | 'font_stroke_width', 'font_vertical_shift', 'logo', 60 | ) 61 | 62 | 63 | def __init__(self, *, 64 | source_file: Path, 65 | card_file: Path, 66 | title_text: str, 67 | season_text: str, 68 | episode_text: str, 69 | hide_season_text: bool = False, 70 | font_color: str = TITLE_COLOR, 71 | font_file: str = TITLE_FONT, 72 | font_interline_spacing: int = 0, 73 | font_kerning: float = 1.0, 74 | font_size: float = 1.0, 75 | font_stroke_width: float = 1.0, 76 | font_vertical_shift: int = 0, 77 | season_number: int = 1, 78 | episode_number: int = 1, 79 | blur: bool = False, 80 | grayscale: bool = False, 81 | logo: Optional[str] = None, 82 | **unused) -> None: 83 | """ 84 | Construct a new instance of this Card. 85 | """ 86 | 87 | # Initialize the parent class - this sets up an ImageMagickInterface 88 | super().__init__(blur, grayscale) 89 | 90 | self.source_file = source_file 91 | self.output_file = card_file 92 | if logo is None: 93 | self.logo = None 94 | else: 95 | try: 96 | logo = logo.format(season_number=season_number, 97 | episode_number=episode_number) 98 | self.logo = Path(CleanPath(logo).sanitize()) 99 | except Exception as e: 100 | self.valid = False 101 | log.exception(f'Invalid logo file "{logo}"', e) 102 | 103 | # Ensure characters that need to be escaped are 104 | self.title_text = self.image_magick.escape_chars(title_text) 105 | self.season_text = self.image_magick.escape_chars(season_text.upper()) 106 | self.episode_text = self.image_magick.escape_chars(episode_text.upper()) 107 | self.hide_season_text = hide_season_text or len(season_text) == 0 108 | 109 | self.font_color = font_color 110 | self.font_file = font_file 111 | self.font_interline_spacing = font_interline_spacing 112 | self.font_kerning = font_kerning 113 | self.font_size = font_size 114 | self.font_stroke_width = font_stroke_width 115 | self.font_vertical_shift = font_vertical_shift 116 | 117 | 118 | @property 119 | def logo_command(self) -> ImageMagickCommands: 120 | """ 121 | Get the ImageMagick commands to add the resized logo to the 122 | source image. 123 | 124 | Returns: 125 | List of ImageMagick commands. 126 | """ 127 | 128 | return [ 129 | # Resize logo 130 | f'\( "{self.logo.resolve()}"', 131 | f'-trim', 132 | f'+repage', 133 | f'-resize x650', 134 | f'-resize 1155x650\> \)', 135 | # Overlay resized logo 136 | f'-gravity northwest', 137 | f'-define colorspace:auto-grayscale=false', 138 | f'-type TrueColorAlpha', 139 | f'-geometry "+50+50"', 140 | f'-composite', 141 | ] 142 | 143 | 144 | @property 145 | def title_text_command(self) -> ImageMagickCommands: 146 | """ 147 | ImageMagick commands to implement the title text's global 148 | effects. Specifically the the font, kerning, fontsize, and 149 | center gravity. 150 | 151 | Returns: 152 | List of ImageMagick commands. 153 | """ 154 | 155 | # Get the title color and stroke for this logo 156 | title_color, stroke_color = self._get_logo_color() 157 | 158 | font_size = 157.41 * self.font_size 159 | interline_spacing = -22 + self.font_interline_spacing 160 | kerning = -1.25 * self.font_kerning 161 | stroke_width = 3.0 * self.font_stroke_width 162 | vertical_shift = 125 + self.font_vertical_shift 163 | 164 | return [ 165 | f'-font "{self.font_file}"', 166 | f'-kerning {kerning}', 167 | f'-interword-spacing 50', 168 | f'-interline-spacing {interline_spacing}', 169 | f'-pointsize {font_size}', 170 | f'-gravity southwest', 171 | f'-fill {stroke_color}', 172 | f'-stroke {stroke_color}', 173 | f'-strokewidth {stroke_width}', 174 | f'-annotate +50+{vertical_shift} "{self.title_text}"', 175 | f'-fill "{title_color}"', 176 | f'-annotate +50+{vertical_shift} "{self.title_text}"', 177 | ] 178 | 179 | 180 | def _get_logo_color(self) -> tuple[str, str]: 181 | """ 182 | Get the logo color for this card's logo. 183 | 184 | Returns: 185 | Tuple whose values are the title color text and the stroke 186 | width color. 187 | """ 188 | 189 | # If auto color wasn't indicated use indicated color and black stroke 190 | if self.font_color.lower() != 'auto': 191 | return self.font_color, 'black' 192 | 193 | # Command to get histogram of the colors in logo image 194 | command = ' '.join([ 195 | f'convert "{self.logo.resolve()}"', 196 | f'-scale 100x100!', 197 | f'-depth 8 +dither', 198 | f'-colors 16', 199 | f'-format "%c" histogram:info:', 200 | ]) 201 | 202 | # Get color data 203 | colordata = self.image_magick.run_get_output(command) 204 | cdata = { 205 | k: [num, hex_] for k, (num, hex_) 206 | in enumerate(findall(self.__COLORDATA_REGEX, colordata), start=1) 207 | } 208 | 209 | translist = [] 210 | pixcount = [] 211 | for key, pair in cdata.items(): 212 | h = int(pair[1][-2:], 16) 213 | if h < 75: 214 | translist.append(key) 215 | else: 216 | pixcount.append(int(pair[0])) 217 | 218 | for key in translist: 219 | del cdata[key] 220 | 221 | # Go through colors in descending order of appearance 222 | pixcount.sort(reverse=True) 223 | pairs = list(cdata.values()) 224 | for num in pixcount: 225 | # Get the RGB value from the hexcolor 226 | hexcolor = next(x[1][:7] for x in pairs if int(x[0]) == num) 227 | 228 | color_ = hexcolor.lstrip('#') 229 | lv = len(color_) 230 | r, g, b = (int(color_[i:i+lv//3], 16) for i in range(0, lv, lv//3)) 231 | 232 | # Skip values that are too dark/light 233 | if min(r, g, b) > 240 or max(r, g, b) < 15: 234 | continue 235 | 236 | # First valid color, return color and stroke based on luminance 237 | luminance = (r * 0.299) + (g * 0.587) + (b * 0.114) 238 | return hexcolor, 'black' if luminance > 50 else 'white' 239 | 240 | # No valid colors identified, return defaults 241 | return self.TITLE_COLOR, 'black' 242 | 243 | 244 | @property 245 | def index_text_command(self) -> ImageMagickCommands: 246 | """ 247 | Get the ImageMagick commands required to add the index (season 248 | and episode) text to the image. 249 | 250 | Returns: 251 | List of ImageMagick commands. 252 | """ 253 | 254 | # Season hiding, just add episode text 255 | if self.hide_season_text: 256 | return [ 257 | f'-kerning 5.42', 258 | f'-pointsize 67.75', 259 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 260 | f'-gravity southwest', 261 | f'-fill black', 262 | f'-stroke black', 263 | f'-strokewidth 6', 264 | f'-annotate +50+50 "{self.episode_text}"', 265 | f'-fill "{self.SERIES_COUNT_TEXT_COLOR}"', 266 | f'-stroke "{self.SERIES_COUNT_TEXT_COLOR}"', 267 | f'-strokewidth 0.75', 268 | f'-annotate +50+50 "{self.episode_text}"', 269 | ] 270 | 271 | return [ 272 | f'-background transparent', 273 | f'+interword-spacing', 274 | f'-kerning 5.42', 275 | f'-pointsize 67.75', 276 | f'-fill black', 277 | f'-stroke black', 278 | f'-strokewidth 6', 279 | f'\( -gravity center', 280 | f'-font "{self.SEASON_COUNT_FONT.resolve()}"', 281 | f'label:"{self.season_text} •"', 282 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 283 | f'label:"{self.episode_text}"', 284 | f'+smush 30 \)', 285 | f'-gravity southwest', 286 | f'-geometry +50+50', 287 | f'-composite', 288 | f'-fill "{self.SERIES_COUNT_TEXT_COLOR}"', 289 | f'-stroke "{self.SERIES_COUNT_TEXT_COLOR}"', 290 | f'-strokewidth 0.75', 291 | f'\( -gravity center', 292 | f'-font "{self.SEASON_COUNT_FONT.resolve()}"', 293 | f'label:"{self.season_text} •"', 294 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 295 | f'label:"{self.episode_text}"', 296 | f'+smush 30 \)', 297 | f'-gravity southwest', 298 | f'-geometry +50+50', 299 | f'-composite', 300 | ] 301 | 302 | 303 | @staticmethod 304 | def is_custom_font(font: 'Font') -> bool: 305 | """ 306 | Determines whether the given arguments represent a custom font 307 | for this card. 308 | 309 | Args: 310 | font: The Font being evaluated. 311 | 312 | Returns: 313 | True if a custom font is indicated, False otherwise. 314 | """ 315 | 316 | return ((font.color != TitleColorMatch.TITLE_COLOR) 317 | or (font.file != TitleColorMatch.TITLE_FONT) 318 | or (font.interline_spacing != 0) 319 | or (font.kerning != 1.0) 320 | or (font.size != 1.0) 321 | or (font.stroke_width != 1.0) 322 | or (font.vertical_shift != 0) 323 | ) 324 | 325 | 326 | @staticmethod 327 | def is_custom_season_titles( 328 | custom_episode_map: bool, episode_text_format: str) -> bool: 329 | """ 330 | Determines whether the given attributes constitute custom or 331 | generic season titles. 332 | 333 | Args: 334 | custom_episode_map: Whether the EpisodeMap was customized. 335 | episode_text_format: The episode text format in use. 336 | 337 | Returns: 338 | True if custom season titles are indicated, False otherwise. 339 | """ 340 | 341 | standard_etf = TitleColorMatch.EPISODE_TEXT_FORMAT.upper() 342 | 343 | return (custom_episode_map or 344 | episode_text_format.upper() != standard_etf) 345 | 346 | 347 | def create(self) -> None: 348 | """ 349 | Make the necessary ImageMagick and system calls to create this 350 | object's defined title card. 351 | """ 352 | 353 | # Skip card if logo doesn't exist 354 | if self.logo is None: 355 | log.error(f'Logo file not specified') 356 | return None 357 | elif not self.logo.exists(): 358 | log.error(f'Logo file "{self.logo.resolve()}" does not exist') 359 | return None 360 | 361 | command = ' '.join([ 362 | f'convert', 363 | # Resize source image 364 | f'"{self.source_file.resolve()}"', 365 | *self.resize_and_style, 366 | # Overlay gradient 367 | f'"{self.__GRADIENT_IMAGE}"', 368 | f'-composite', 369 | # Overlay resized logo 370 | *self.logo_command, 371 | # Put title text 372 | *self.title_text_command, 373 | # Put season/episode text 374 | *self.index_text_command, 375 | # Create and resize output 376 | *self.resize_output, 377 | f'"{self.output_file.resolve()}"', 378 | ]) 379 | 380 | self.image_magick.run(command) -------------------------------------------------------------------------------- /Wdvh/WhiteTextStandardLogo.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from re import findall 3 | from typing import Optional 4 | 5 | from modules.BaseCardType import BaseCardType 6 | from modules.RemoteFile import RemoteFile 7 | from modules.Debug import log 8 | 9 | class WhiteTextStandardLogo(BaseCardType): 10 | """ 11 | WDVH's WhiteTextStandardLogo card type. 12 | """ 13 | 14 | """Directory where all reference files used by this card are stored""" 15 | REF_DIRECTORY = Path(__file__).parent.parent / 'ref' 16 | 17 | """Characteristics for title splitting by this class""" 18 | TITLE_CHARACTERISTICS = { 19 | 'max_line_width': 32, # Character count to begin splitting titles 20 | 'max_line_count': 3, # Maximum number of lines a title can take up 21 | 'top_heavy': False, # This class uses bottom heavy titling 22 | } 23 | 24 | """Default font and text color for episode title text""" 25 | TITLE_FONT = str(RemoteFile('Wdvh', 'TerminalDosis-Bold.ttf')) 26 | TITLE_COLOR = '#FFFFFF' 27 | 28 | """Default characters to replace in the generic font""" 29 | FONT_REPLACEMENTS = {} 30 | 31 | """Whether this CardType uses season titles for archival purposes""" 32 | USES_SEASON_TITLE = True 33 | 34 | """Whether this CardType uses unique source images""" 35 | USES_UNIQUE_SOURCES = False 36 | 37 | """Standard class has standard archive name""" 38 | ARCHIVE_NAME = 'White Text Standard Logo Style' 39 | 40 | """Default fonts and color for series count text""" 41 | SEASON_COUNT_FONT = REF_DIRECTORY / 'Sequel-Neue.otf' 42 | EPISODE_COUNT_FONT = REF_DIRECTORY / 'Sequel-Neue.otf' 43 | SERIES_COUNT_TEXT_COLOR = '#FFFFFF' 44 | 45 | """Paths to intermediate files that are deleted after the card is created""" 46 | __RESIZED_LOGO = BaseCardType.TEMP_DIR / 'resized_logo.png' 47 | __BACKDROP_WITH_LOGO = BaseCardType.TEMP_DIR / 'backdrop_logo.png' 48 | __LOGO_WITH_TITLE = BaseCardType.TEMP_DIR / 'logo_title.png' 49 | __SERIES_COUNT_TEXT = BaseCardType.TEMP_DIR / 'series_count_text.png' 50 | 51 | __slots__ = ( 52 | 'logo', 'output_file', 'title', 'season_text', 'episode_text', 'font', 53 | 'font_size', 'title_color', 'hide_season', 'separator','vertical_shift', 54 | 'interline_spacing', 'kerning', 'stroke_width', 'background', 55 | ) 56 | 57 | 58 | def __init__(self, *, 59 | card_file: Path, 60 | title_text: str, 61 | season_text: str, 62 | episode_text: str, 63 | hide_season_text: bool = False, 64 | season_number: int = 1, 65 | episode_number: int = 1, 66 | font_color: str = TITLE_COLOR, 67 | font_file: str = TITLE_FONT, 68 | font_kerning: float = 1.0, 69 | font_interline_spacing: int = 0, 70 | font_size: float = 1.0, 71 | font_stroke_width: float = 1.0, 72 | font_vertical_shift: int = 0, 73 | blur: bool = False, 74 | grayscale: bool = False, 75 | logo: Optional[str] = None, 76 | background: str = '#000000', 77 | separator: str = '-', 78 | **unused) -> None: 79 | """ 80 | Initialize this CardType object. 81 | """ 82 | 83 | # Initialize the parent class - this sets up an ImageMagickInterface 84 | super().__init__(blur, grayscale) 85 | 86 | # Look for logo if it's a format string 87 | if isinstance(logo, str): 88 | try: 89 | logo = logo.format( 90 | season_number=season_number, episode_number=episode_number 91 | ) 92 | except Exception: 93 | pass 94 | 95 | # Use either original or modified logo file 96 | self.logo = Path(logo) 97 | else: 98 | self.logo = None 99 | 100 | self.output_file = card_file 101 | 102 | # Ensure characters that need to be escaped are 103 | self.title = self.image_magick.escape_chars(title_text) 104 | self.season_text = self.image_magick.escape_chars(season_text.upper()) 105 | self.episode_text = self.image_magick.escape_chars(episode_text.upper()) 106 | self.hide_season = hide_season_text 107 | 108 | self.font = font_file 109 | self.font_size = font_size 110 | self.title_color = font_color 111 | self.vertical_shift = font_vertical_shift 112 | self.interline_spacing = font_interline_spacing 113 | self.kerning = font_kerning 114 | self.stroke_width = font_stroke_width 115 | 116 | self.background = background 117 | self.separator = separator 118 | 119 | 120 | def __title_text_global_effects(self) -> list[str]: 121 | """ 122 | ImageMagick commands to implement the title text's global effects. 123 | Specifically the the font, kerning, fontsize, and center gravity. 124 | 125 | Returns: 126 | List of ImageMagick commands. 127 | """ 128 | 129 | font_size = 180 * self.font_size 130 | interline_spacing = -70 + self.interline_spacing 131 | kerning = -1.25 * self.kerning 132 | 133 | return [ 134 | f'-font "{self.font}"', 135 | f'-kerning {kerning}', 136 | f'-interword-spacing 50', 137 | f'-interline-spacing {interline_spacing}', 138 | f'-pointsize {font_size}', 139 | f'-gravity south', 140 | ] 141 | 142 | 143 | def __title_text_black_stroke(self) -> list[str]: 144 | """ 145 | ImageMagick commands to implement the title text's black stroke. 146 | 147 | Returns: 148 | List of ImageMagick commands. 149 | """ 150 | 151 | stroke_width = 4.0 * self.stroke_width 152 | 153 | return [ 154 | f'-fill white', 155 | f'-stroke "#062A40"', 156 | f'-strokewidth {stroke_width}', 157 | ] 158 | 159 | 160 | def __series_count_text_global_effects(self) -> list[str]: 161 | """ 162 | ImageMagick commands for global text effects applied to all series count 163 | text (season/episode count and dot). 164 | 165 | Returns: 166 | List of ImageMagick commands. 167 | """ 168 | 169 | return [ 170 | f'-kerning 5.42', 171 | f'-pointsize 85', 172 | ] 173 | 174 | 175 | def __series_count_text_black_stroke(self) -> list[str]: 176 | """ 177 | ImageMagick commands for adding the necessary black stroke effects to 178 | series count text. 179 | 180 | Returns: 181 | List of ImageMagick commands. 182 | """ 183 | 184 | return [ 185 | f'-fill white', 186 | f'-stroke "#062A40"', 187 | f'-strokewidth 2', 188 | ] 189 | 190 | 191 | def __series_count_text_effects(self) -> list[str]: 192 | """ 193 | ImageMagick commands for adding the necessary text effects to the series 194 | count text. 195 | 196 | Returns: 197 | List of ImageMagick commands. 198 | """ 199 | 200 | return [ 201 | f'-fill white', 202 | f'-stroke "#062A40"', 203 | f'-strokewidth 2', 204 | ] 205 | 206 | 207 | def _resize_logo(self) -> Path: 208 | """ 209 | Resize the logo into at most a 1875x1030 bounding box. 210 | 211 | Returns: 212 | Path to the created image. 213 | """ 214 | 215 | command = ' '.join([ 216 | f'convert', 217 | f'"{self.logo.resolve()}"', 218 | f'-resize x1030', 219 | f'-resize 1875x1030\>', 220 | f'"{self.__RESIZED_LOGO.resolve()}"', 221 | ]) 222 | 223 | self.image_magick.run(command) 224 | 225 | return self.__RESIZED_LOGO 226 | 227 | 228 | def _add_logo_to_backdrop(self, resized_logo: Path) -> Path: 229 | """ 230 | Add the resized logo to a fixed color backdrop. 231 | 232 | Returns: 233 | Path to the created image. 234 | """ 235 | 236 | # Get height of the resized logo to determine offset 237 | height_command = ' '.join([ 238 | f'identify', 239 | f'-format "%h"', 240 | f'"{resized_logo.resolve()}"', 241 | ]) 242 | 243 | height = int(self.image_magick.run_get_output(height_command)) 244 | 245 | # Get offset of where to place logo onto card 246 | offset = 60 + ((1030 - height) // 2) 247 | 248 | command = ' '.join([ 249 | f'convert', 250 | f'-size "{self.TITLE_CARD_SIZE}"', # Create backdrop 251 | f'xc:"{self.background}"', # Fill canvas with color 252 | f'"{resized_logo.resolve()}"', 253 | f'-set colorspace sRGB', 254 | f'-gravity north', 255 | f'-geometry "+0+{offset}"', # Put logo on backdrop 256 | f'-composite "{self.__BACKDROP_WITH_LOGO.resolve()}"', 257 | ]) 258 | 259 | self.image_magick.run(command) 260 | 261 | return self.__BACKDROP_WITH_LOGO 262 | 263 | 264 | def _add_title_text(self, backdrop_logo: Path) -> Path: 265 | """ 266 | Adds episode title text to the provide image. 267 | 268 | :param backdrop_logo: The backdrop and logo image. 269 | 270 | :returns: Path to the created image that has the title text added. 271 | """ 272 | 273 | vertical_shift = 245 + self.vertical_shift 274 | 275 | command = ' '.join([ 276 | f'convert "{backdrop_logo.resolve()}"', 277 | *self.resize_and_style, 278 | *self.__title_text_global_effects(), 279 | *self.__title_text_black_stroke(), 280 | f'-annotate +0+{vertical_shift} "{self.title}"', 281 | f'-fill "{self.title_color}"', 282 | f'-annotate +0+{vertical_shift} "{self.title}"', 283 | f'"{self.__LOGO_WITH_TITLE.resolve()}"', 284 | ]) 285 | 286 | self.image_magick.run(command) 287 | 288 | return self.__LOGO_WITH_TITLE 289 | 290 | 291 | def _add_series_count_text_no_season(self, titled_image: Path) -> Path: 292 | """ 293 | Adds the series count text without season title/number. 294 | 295 | :param titled_image: The titled image to add text to. 296 | 297 | :returns: Path to the created image (the output file). 298 | """ 299 | 300 | command = ' '.join([ 301 | f'convert "{titled_image.resolve()}"', 302 | *self.__series_count_text_global_effects(), 303 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 304 | f'-gravity center', 305 | *self.__series_count_text_black_stroke(), 306 | f'-annotate +0+697.2 "{self.episode_text}"', 307 | *self.__series_count_text_effects(), 308 | f'-annotate +0+697.2 "{self.episode_text}"', 309 | *self.resize_output, 310 | f'"{self.output_file.resolve()}"', 311 | ]) 312 | 313 | self.image_magick.run(command) 314 | 315 | return self.output_file 316 | 317 | 318 | def _get_series_count_text_dimensions(self) -> dict: 319 | """ 320 | Gets the series count text dimensions. 321 | 322 | :returns: The series count text dimensions. 323 | """ 324 | 325 | command = ' '.join([ 326 | f'convert -debug annotate xc: ', 327 | *self.__series_count_text_global_effects(), 328 | f'-font "{self.SEASON_COUNT_FONT.resolve()}"', 329 | f'-gravity east', 330 | *self.__series_count_text_effects(), 331 | f'-annotate +1600+697.2 "{self.season_text} "', 332 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 333 | f'-gravity center', 334 | *self.__series_count_text_effects(), 335 | f'-annotate +0+689.5 "{self.separator} "', 336 | f'-gravity west', 337 | *self.__series_count_text_effects(), 338 | f'-annotate +1640+697.2 "{self.episode_text}"', 339 | f'null: 2>&1' 340 | ]) 341 | 342 | # Get text dimensions from the output 343 | metrics = self.image_magick.run_get_output(command) 344 | widths = list(map(int, findall(r'Metrics:.*width:\s+(\d+)', metrics))) 345 | heights = list(map(int, findall(r'Metrics:.*height:\s+(\d+)', metrics))) 346 | 347 | # Don't raise IndexError if no dimensions were found 348 | if len(widths) < 2 or len(heights) < 2: 349 | log.warning(f'Unable to identify font dimensions, file bug report') 350 | widths = [370, 47, 357] 351 | heights = [68, 83, 83] 352 | 353 | return { 354 | 'width': sum(widths), 355 | 'width1': widths[0], 356 | 'width2': widths[1], 357 | 'height': max(heights)+25, 358 | } 359 | 360 | 361 | def _create_series_count_text_image(self, 362 | width: float, width1: float, width2: float, height: float) -> Path: 363 | """ 364 | Creates an image with only series count text. This image is transparent, 365 | and not any wider than is necessary (as indicated by `dimensions`). 366 | 367 | :returns: Path to the created image containing only series count text. 368 | """ 369 | 370 | # Create text only transparent image of season count text 371 | command = ' '.join([ 372 | f'convert -size "{width}x{height}"', 373 | f'-alpha on', 374 | f'-background transparent', 375 | f'xc:transparent', 376 | *self.__series_count_text_global_effects(), 377 | f'-font "{self.SEASON_COUNT_FONT.resolve()}"', 378 | *self.__series_count_text_black_stroke(), 379 | f'-annotate +0+{height-25} "{self.season_text} "', 380 | *self.__series_count_text_effects(), 381 | f'-annotate +0+{height-25} "{self.season_text} "', 382 | f'-font "{self.EPISODE_COUNT_FONT.resolve()}"', 383 | *self.__series_count_text_black_stroke(), 384 | f'-annotate +{width1}+{height-25-6.5} "{self.separator}"', 385 | *self.__series_count_text_effects(), 386 | f'-annotate +{width1}+{height-25-6.5} "{self.separator}"', 387 | *self.__series_count_text_black_stroke(), 388 | f'-annotate +{width1+width2}+{height-25} "{self.episode_text}"', 389 | *self.__series_count_text_effects(), 390 | f'-annotate +{width1+width2}+{height-25} "{self.episode_text}"', 391 | f'"PNG32:{self.__SERIES_COUNT_TEXT.resolve()}"', 392 | ]) 393 | 394 | self.image_magick.run(command) 395 | 396 | return self.__SERIES_COUNT_TEXT 397 | 398 | 399 | def _combine_titled_image_series_count_text(self, titled_image: Path, 400 | series_count_image: Path)->Path: 401 | """ 402 | Combine the titled image (image+backdrop+episode title) and the series 403 | count image (optional season number+optional dot+episode number) into a 404 | single image. This is written into the output image for this object. 405 | 406 | :param titled_image: Path to the titled image to add. 407 | :param series_count_image: Path to the series count transparent 408 | image to add. 409 | 410 | :returns: Path to the created image (the output file). 411 | """ 412 | 413 | command = ' '.join([ 414 | f'convert', 415 | f'"{titled_image.resolve()}"', 416 | f'-gravity center', 417 | f'"{series_count_image.resolve()}"', 418 | f'-geometry +0+690.2', 419 | f'-composite', 420 | *self.resize_output, 421 | f'"{self.output_file.resolve()}"', 422 | ]) 423 | 424 | self.image_magick.run(command) 425 | 426 | return self.output_file 427 | 428 | 429 | @staticmethod 430 | def is_custom_font(font: 'Font') -> bool: 431 | """ 432 | Determines whether the given font characteristics constitute a 433 | default or custom font. 434 | 435 | Args: 436 | font: The Font being evaluated. 437 | 438 | Returns: 439 | True if a custom font is indicated, False otherwise. 440 | """ 441 | 442 | return ((font.color != WhiteTextStandardLogo.TITLE_COLOR) 443 | or (font.file != WhiteTextStandardLogo.TITLE_FONT) 444 | or (font.interline_spacing != 0) 445 | or (font.kerning != 1.0) 446 | or (font.size != 1.0) 447 | or (font.stroke_width != 1.0) 448 | or (font.vertical_shift != 0) 449 | ) 450 | 451 | 452 | @staticmethod 453 | def is_custom_season_titles( 454 | custom_episode_map: bool, episode_text_format: str) -> bool: 455 | """ 456 | Determines whether the given attributes constitute custom or 457 | generic season titles. 458 | 459 | Args: 460 | custom_episode_map: Whether the EpisodeMap was customized. 461 | episode_text_format: The episode text format in use. 462 | 463 | Returns: 464 | True if custom season title are indicated. False otherwise. 465 | """ 466 | 467 | standard_etf = WhiteTextStandardLogo.EPISODE_TEXT_FORMAT.upper() 468 | 469 | return (custom_episode_map or 470 | episode_text_format.upper() != standard_etf) 471 | 472 | 473 | def create(self) -> None: 474 | """ 475 | Make the necessary ImageMagick and system calls to create this 476 | object's defined title card. 477 | """ 478 | 479 | # Skip card if logo doesn't exist 480 | if self.logo is None: 481 | log.error(f'Logo file not specified') 482 | return None 483 | elif not self.logo.exists(): 484 | log.error(f'Logo file "{self.logo.resolve()}" does not exist') 485 | return None 486 | 487 | # Resize logo 488 | resized_logo = self._resize_logo() 489 | 490 | # Create backdrop+logo image 491 | backdrop_logo = self._add_logo_to_backdrop(resized_logo) 492 | 493 | # Add either one or two lines of episode text 494 | titled_image = self._add_title_text(backdrop_logo) 495 | 496 | # If season text is hidden, just add episode text 497 | if self.hide_season: 498 | self._add_series_count_text_no_season(titled_image) 499 | else: 500 | # If adding season text, create intermediate images and combine them 501 | series_count_image = self._create_series_count_text_image( 502 | **self._get_series_count_text_dimensions() 503 | ) 504 | self._combine_titled_image_series_count_text( 505 | titled_image, 506 | series_count_image 507 | ) 508 | 509 | # Delete all intermediate images 510 | images = [resized_logo, backdrop_logo, titled_image] 511 | if not self.hide_season: 512 | images.append(series_count_image) 513 | 514 | self.image_magick.delete_intermediate_images(*images) -------------------------------------------------------------------------------- /KHthe8th/TintedFramePlusTitleCard.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Literal, Optional 3 | 4 | from modules.Debug import log 5 | from modules.BaseCardType import ( 6 | BaseCardType, Coordinate, ImageMagickCommands, Rectangle, 7 | ) 8 | 9 | SeriesExtra = Optional 10 | Element = Literal['index', 'logo', 'omit'] 11 | MiddleElement = Literal['logo', 'omit'] 12 | 13 | 14 | class TintedFramePlusTitleCard(BaseCardType): 15 | """ 16 | CardType that produces title cards featuring a rectangular frame 17 | with blurred content on the edges of the frame, and unblurred 18 | content within. The frame itself can be intersected by title text, 19 | index text, or a logo at the top and bottom. 20 | """ 21 | 22 | """Directory where all reference files used by this card are stored""" 23 | REF_DIRECTORY = BaseCardType.BASE_REF_DIRECTORY / 'tinted_frame' 24 | 25 | """Characteristics for title splitting by this class""" 26 | TITLE_CHARACTERISTICS = { 27 | 'max_line_width': 35, # Character count to begin splitting titles 28 | 'max_line_count': 2, # Maximum number of lines a title can take up 29 | 'top_heavy': True, # This class uses top heavy titling 30 | } 31 | 32 | """Characteristics of the default title font""" 33 | TITLE_FONT = str((REF_DIRECTORY / 'Galey Semi Bold.ttf').resolve()) 34 | TITLE_COLOR = 'white' 35 | DEFAULT_FONT_CASE = 'upper' 36 | FONT_REPLACEMENTS = {} 37 | 38 | """Characteristics of the episode text""" 39 | EPISODE_TEXT_COLOR = TITLE_COLOR 40 | EPISODE_TEXT_FONT = REF_DIRECTORY / 'Galey Semi Bold.ttf' 41 | 42 | """Whether this CardType uses season titles for archival purposes""" 43 | USES_SEASON_TITLE = True 44 | 45 | """Standard class has standard archive name""" 46 | ARCHIVE_NAME = 'Tinted Frame Style' 47 | 48 | """How many pixels from the image edge the box is placed; and box width""" 49 | BOX_OFFSET = 185 50 | BOX_WIDTH = 3 51 | 52 | __slots__ = ( 53 | 'source_file', 'output_file', 'title_text', 'season_text', 54 | 'episode_text', 'hide_season_text', 'hide_episode_text', 'font_file', 55 | 'font_size', 'font_color', 'font_interline_spacing', 56 | 'font_interword_spacing', 'font_kerning', 'font_stroke_width','font_vertical_shift', 57 | 'episode_text_color', 'stroke_color','separator', 'frame_color', 'logo', 'top_element', 58 | 'middle_element', 'bottom_element', 'logo_size', 'blur_edges', 59 | 'episode_text_font', 'frame_width', 'episode_text_font_size', 60 | 'episode_text_vertical_shift', 61 | ) 62 | 63 | def __init__(self, *, 64 | source_file: Path, 65 | card_file: Path, 66 | title_text: str, 67 | season_text: str, 68 | episode_text: str, 69 | hide_season_text: bool = False, 70 | hide_episode_text: bool = False, 71 | font_color: str = TITLE_COLOR, 72 | font_file: str = TITLE_FONT, 73 | font_interline_spacing: int = 0, 74 | font_interword_spacing: int = 0, 75 | font_kerning: float = 1.0, 76 | font_size: float = 1.0, 77 | font_stroke_width: float = 1.0, 78 | font_vertical_shift: int = 0, 79 | season_number: int = 1, 80 | episode_number: int = 1, 81 | blur: bool = False, 82 | grayscale: bool = False, 83 | separator: str = '-', 84 | stroke_color: str = 'black', 85 | episode_text_color: str = None, 86 | episode_text_font: Path = EPISODE_TEXT_FONT, 87 | episode_text_font_size: float = 1.0, 88 | episode_text_vertical_shift: int = 0, 89 | frame_color: str = None, 90 | frame_width: int = BOX_WIDTH, 91 | top_element: Element = 'logo', 92 | middle_element: MiddleElement = 'omit', 93 | bottom_element: Element = 'index', 94 | logo: SeriesExtra[str] = None, 95 | logo_size: SeriesExtra[float] = 1.0, 96 | blur_edges: bool = True, 97 | preferences: Optional['Preferences'] = None, # type: ignore 98 | **unused, 99 | ) -> None: 100 | """ 101 | Construct a new instance of this Card. 102 | """ 103 | 104 | # Initialize the parent class - this sets up an ImageMagickInterface 105 | super().__init__(blur, grayscale, preferences=preferences) 106 | 107 | self.source_file = source_file 108 | self.output_file = card_file 109 | 110 | # Ensure characters that need to be escaped are 111 | self.title_text = self.image_magick.escape_chars(title_text) 112 | self.season_text = self.image_magick.escape_chars(season_text.upper()) 113 | self.episode_text = self.image_magick.escape_chars(episode_text.upper()) 114 | self.hide_season_text = hide_season_text or len(season_text) == 0 115 | self.hide_episode_text = hide_episode_text or len(episode_text) == 0 116 | 117 | # Font/card customizations 118 | self.font_color = font_color 119 | self.font_file = font_file 120 | self.font_interline_spacing = font_interline_spacing 121 | self.font_interword_spacing = font_interword_spacing 122 | self.font_kerning = font_kerning 123 | self.font_size = font_size 124 | self.font_stroke_width = font_stroke_width 125 | self.font_vertical_shift = font_vertical_shift 126 | 127 | # Optional extras 128 | self.separator = separator 129 | self.frame_color = font_color if frame_color is None else frame_color 130 | self.frame_width = frame_width 131 | self.logo_size = logo_size 132 | self.blur_edges = blur_edges 133 | self.stroke_color = stroke_color 134 | self.episode_text_font_size = episode_text_font_size 135 | self.episode_text_vertical_shift = episode_text_vertical_shift 136 | if episode_text_color is None: 137 | self.episode_text_color = font_color 138 | else: 139 | self.episode_text_color = episode_text_color 140 | 141 | # If a logo was provided, convert to Path object 142 | if logo is None: 143 | self.logo = None 144 | else: 145 | try: 146 | self.logo = Path( 147 | str(logo).format( 148 | season_number=season_number, 149 | episode_number=episode_number 150 | ) 151 | ) 152 | except Exception as e: 153 | log.exception(f'Logo path is invalid', e) 154 | self.valid = False 155 | 156 | # Validate top, middle, and bottom elements 157 | def _validate_element(element: str, middle: bool = False) -> str: 158 | element = str(element).strip().lower() 159 | if middle and element not in ('omit', 'logo'): 160 | log.warning(f'Invalid element - must be "omit" or "logo') 161 | self.valid = False 162 | elif (not middle 163 | and element not in ('omit', 'index', 'logo')): 164 | log.warning(f'Invalid element - must be "omit", ' 165 | f'"index", or "logo"') 166 | self.valid = False 167 | return element 168 | self.top_element = _validate_element(top_element) 169 | self.middle_element = _validate_element(middle_element, middle=True) 170 | self.bottom_element = _validate_element(bottom_element) 171 | 172 | # Validate no duplicate elements were indicated 173 | if ((self.top_element != 'omit' 174 | and (self.top_element == self.middle_element 175 | or self.top_element == self.bottom_element)) 176 | or (self.middle_element != 'omit' 177 | and self.middle_element == self.bottom_element)): 178 | log.warning(f'Top/middle/bottom elements cannot be the same') 179 | self.valid = False 180 | 181 | # If logo was indicated, verify logo was provided 182 | if (self.logo is None 183 | and ('logo' in (self.top_element, self.middle_element, 184 | self.bottom_element))): 185 | log.warning(f'Logo file not provided') 186 | self.valid = False 187 | 188 | try: 189 | self.episode_text_font = Path(episode_text_font) 190 | except Exception as exc: 191 | log.exception(f'Invalid episode text font', exc) 192 | self.valid = False 193 | 194 | 195 | @property 196 | def blur_commands(self) -> ImageMagickCommands: 197 | """ 198 | Subcommand to blur the outer frame of the source image (if 199 | indicated). 200 | """ 201 | 202 | # Blurring is disabled (or being applied globally), return empty command 203 | if not self.blur_edges or self.blur: 204 | return [] 205 | 206 | crop_width = self.WIDTH - (2 * self.BOX_OFFSET) - 6 # 6px margin 207 | crop_height = self.HEIGHT - (2 * self.BOX_OFFSET) - 4 # 4px margin 208 | 209 | return [ 210 | # Blur entire image 211 | f'-blur 0x20', 212 | # Crop out center area of the source image 213 | f'-gravity center', 214 | f'\( "{self.source_file.resolve()}"', 215 | *self.resize_and_style, 216 | f'-crop {crop_width}x{crop_height}+0+0', 217 | f'+repage \)', 218 | # Overlay unblurred center area 219 | f'-composite', 220 | ] 221 | 222 | 223 | @property 224 | def index_text_commands(self) -> ImageMagickCommands: 225 | """Subcommand for adding index text to the source image.""" 226 | 227 | # If not showing index text, or all text is hidden, return 228 | if ((self.top_element != 'index' and self.bottom_element != 'index') 229 | or (self.hide_season_text and self.hide_episode_text)): 230 | return [] 231 | 232 | # Set index text based on which text is hidden/not 233 | if self.hide_season_text: 234 | index_text = self.episode_text 235 | elif self.hide_episode_text: 236 | index_text = self.season_text 237 | else: 238 | index_text = f'{self.season_text} {self.separator} {self.episode_text}' 239 | 240 | # Determine vertical position based on which element this text is 241 | if self.top_element == 'index': 242 | vertical_shift = -708 243 | else: 244 | vertical_shift = 722 245 | vertical_shift += self.episode_text_vertical_shift 246 | 247 | return [ 248 | f'-background transparent', 249 | f'\( -font "{self.episode_text_font.resolve()}"', 250 | f'+kerning +interline-spacing +interword-spacing', 251 | f'-pointsize {60 * self.episode_text_font_size}', 252 | f'-fill "{self.episode_text_color}"', 253 | f'label:"{index_text}"', 254 | # Create drop shadow 255 | f'\( +clone', 256 | f'-shadow 80x3+6+6 \)', 257 | # Position shadow below text 258 | f'+swap', 259 | f'-layers merge', 260 | f'+repage \)', 261 | # Overlay text and shadow onto source image 262 | f'-gravity center', 263 | f'-geometry +0{vertical_shift:+}', 264 | f'-composite', 265 | ] 266 | 267 | 268 | @property 269 | def logo_commands(self) -> ImageMagickCommands: 270 | """ 271 | Subcommand for adding the logo to the image if indicated by 272 | either extra (and the logo file exists). 273 | """ 274 | 275 | # Logo not indicated or not available, return empty commands 276 | if ((self.top_element != 'logo' 277 | and self.middle_element != 'logo' 278 | and self.bottom_element != 'logo') 279 | or self.logo is None or not self.logo.exists()): 280 | return [] 281 | 282 | # Determine vertical position based on which element the logo is 283 | if self.top_element == 'logo': 284 | vertical_shift = -720 285 | elif self.middle_element == 'logo': 286 | vertical_shift = 0 287 | elif self.bottom_element == 'logo': 288 | vertical_shift = 700 289 | else: 290 | vertical_shift = 0 291 | 292 | # Determine logo height 293 | if self.middle_element == 'logo': 294 | logo_height = 350 * self.logo_size 295 | else: 296 | logo_height = 150 * self.logo_size 297 | 298 | # Determine resizing for the logo 299 | if self.middle_element == 'logo': 300 | # Constrain by width and height 301 | resize_command = [ 302 | f'-resize x{logo_height}', 303 | f'-resize {2500 * self.logo_size}x{logo_height}\>', 304 | ] 305 | else: 306 | resize_command = [f'-resize x{logo_height}'] 307 | 308 | return [ 309 | f'\( "{self.logo.resolve()}"', 310 | *resize_command, 311 | f'\) -gravity center', 312 | f'-geometry +0{vertical_shift:+}', 313 | f'-composite', 314 | ] 315 | 316 | 317 | @property 318 | def _frame_top_commands(self) -> ImageMagickCommands: 319 | """ 320 | Subcommand to add the top of the frame, intersected by the 321 | selected element. 322 | """ 323 | 324 | # Coordinates used by multiple rectangles 325 | INSET = self.BOX_OFFSET 326 | BOX_WIDTH = self.frame_width 327 | TopLeft = Coordinate(INSET, INSET) 328 | TopRight = Coordinate(self.WIDTH - INSET, INSET + BOX_WIDTH) 329 | 330 | # This frame is uninterrupted, draw single rectangle 331 | if (self.top_element == 'omit' 332 | or (self.top_element == 'index' 333 | and self.hide_season_text and self.hide_episode_text) 334 | or (self.top_element == 'logo' 335 | and (self.logo is None or not self.logo.exists()))): 336 | 337 | return [Rectangle(TopLeft, TopRight).draw()] 338 | 339 | # Element is index text 340 | if self.top_element == 'index': 341 | element_width, _ = self.get_text_dimensions( 342 | self.index_text_commands, width='max', height='max', 343 | ) 344 | margin = 25 345 | # Element is logo 346 | elif self.top_element == 'logo': 347 | element_width, logo_height = self.image_magick.get_image_dimensions( 348 | self.logo 349 | ) 350 | element_width /= (logo_height / 150) 351 | element_width *= self.logo_size 352 | margin = 25 353 | 354 | # Determine bounds based on element width 355 | left_box_x = (self.WIDTH / 2) - (element_width / 2) - margin 356 | right_box_x = (self.WIDTH / 2) + (element_width / 2) + margin 357 | 358 | # If the boundaries are wider than the start of the frame, draw nothing 359 | if left_box_x < INSET or right_box_x > (self.WIDTH - INSET): 360 | return [] 361 | 362 | # Create Rectangles for these two frame sections 363 | top_left_rectangle = Rectangle( 364 | TopLeft, 365 | Coordinate(left_box_x, INSET + BOX_WIDTH) 366 | ) 367 | top_right_rectangle = Rectangle( 368 | Coordinate(right_box_x, INSET), 369 | TopRight, 370 | ) 371 | 372 | return [ 373 | top_left_rectangle.draw(), 374 | top_right_rectangle.draw() 375 | ] 376 | 377 | 378 | @property 379 | def _frame_bottom_commands(self) -> ImageMagickCommands: 380 | """ 381 | Subcommand to add the bottom of the frame, intersected by the 382 | selected element. 383 | """ 384 | 385 | # Coordinates used by multiple rectangles 386 | INSET = self.BOX_OFFSET 387 | BOX_WIDTH = self.frame_width 388 | # BottomLeft = Coordinate(INSET + BOX_WIDTH, self.HEIGHT - INSET) 389 | BottomRight = Coordinate(self.WIDTH - INSET, self.HEIGHT - INSET) 390 | 391 | # This frame is uninterrupted, draw single rectangle 392 | if (self.bottom_element == 'omit' 393 | or (self.bottom_element == 'index' 394 | and self.hide_season_text and self.hide_episode_text) 395 | or (self.bottom_element == 'logo' 396 | and (self.logo is None or not self.logo.exists()))): 397 | 398 | return [ 399 | Rectangle( 400 | Coordinate(INSET, self.HEIGHT - INSET - BOX_WIDTH), 401 | BottomRight 402 | ).draw() 403 | ] 404 | 405 | # Element is index text 406 | if self.bottom_element == 'index': 407 | element_width, _ = self.get_text_dimensions( 408 | self.index_text_commands, width='max', height='max', 409 | ) 410 | margin = 25 411 | # Element is logo 412 | elif self.bottom_element == 'logo': 413 | element_width, logo_height = self.image_magick.get_image_dimensions( 414 | self.logo 415 | ) 416 | element_width /= (logo_height / 150) 417 | element_width *= self.logo_size 418 | margin = 25 419 | 420 | # Determine bounds based on element width 421 | left_box_x = (self.WIDTH / 2) - (element_width / 2) - margin 422 | right_box_x = (self.WIDTH / 2) + (element_width / 2) + margin 423 | 424 | # If the boundaries are wider than the start of the frame, draw nothing 425 | if left_box_x < INSET or right_box_x > (self.WIDTH - INSET): 426 | return [] 427 | 428 | # Create Rectangles for these two frame sections 429 | bottom_left_rectangle = Rectangle( 430 | Coordinate(INSET, self.HEIGHT - INSET - BOX_WIDTH), 431 | Coordinate(left_box_x, self.HEIGHT - INSET) 432 | ) 433 | bottom_right_rectangle = Rectangle( 434 | Coordinate(right_box_x, self.HEIGHT - INSET - BOX_WIDTH), 435 | BottomRight, 436 | ) 437 | 438 | return [ 439 | bottom_left_rectangle.draw(), 440 | bottom_right_rectangle.draw(), 441 | ] 442 | 443 | 444 | @property 445 | def frame_commands(self) -> ImageMagickCommands: 446 | """ 447 | Subcommands to add the box that separates the outer (blurred) 448 | image and the interior (unblurred) image. This box features a 449 | drop shadow. The top and bottom parts of the frame are 450 | optionally intersected by a index text, title text, or a logo. 451 | """ 452 | 453 | # Coordinates used by multiple rectangles 454 | INSET = self.BOX_OFFSET 455 | BOX_WIDTH = self.frame_width 456 | TopLeft = Coordinate(INSET, INSET) 457 | # TopRight = Coordinate(self.WIDTH - INSET, INSET + BOX_WIDTH) 458 | BottomLeft = Coordinate(INSET + BOX_WIDTH, self.HEIGHT - INSET) 459 | BottomRight = Coordinate(self.WIDTH - INSET, self.HEIGHT - INSET) 460 | 461 | # Determine frame draw commands 462 | top = self._frame_top_commands 463 | left = [Rectangle(TopLeft, BottomLeft).draw()] 464 | right = [ 465 | Rectangle( 466 | Coordinate(self.WIDTH - INSET - BOX_WIDTH, INSET), 467 | BottomRight, 468 | ).draw() 469 | ] 470 | bottom = self._frame_bottom_commands 471 | 472 | return [ 473 | # Create blank canvas 474 | f'\( -size {self.TITLE_CARD_SIZE}', 475 | f'xc:transparent', 476 | # Draw all sets of rectangles 477 | f'+stroke', 478 | f'-fill "{self.frame_color}"', 479 | *top, *left, *right, *bottom, 480 | f'\( +clone', 481 | f'-shadow 80x3+4+4 \)', 482 | # Position drop shadow below rectangles 483 | f'+swap', 484 | f'-layers merge', 485 | f'+repage \)', 486 | # Overlay box and shadow onto source image 487 | f'-geometry +0+0', 488 | f'-composite', 489 | ] 490 | 491 | 492 | @property 493 | def mask_commands(self) -> ImageMagickCommands: 494 | """ 495 | Subcommands to add the top-level mask which overlays all other 496 | elements of the image, even the frame. This mask can be used to 497 | have parts of the image appear to "pop out" of the frame. 498 | """ 499 | 500 | # Do not apply mask if stylized 501 | if self.blur or self.grayscale: 502 | return [] 503 | 504 | # Look for mask file corresponding to this source image 505 | mask = self.source_file.parent / f'{self.source_file.stem}-mask.png' 506 | 507 | # Mask exists, return commands to compose atop image 508 | if mask.exists(): 509 | return [ 510 | f'\( "{mask.resolve()}"', 511 | *self.resize_and_style, 512 | f'\) -composite', 513 | ] 514 | 515 | return [] 516 | 517 | @staticmethod 518 | def modify_extras( 519 | extras: dict, 520 | custom_font: bool, 521 | custom_season_titles: bool, 522 | ) -> None: 523 | """ 524 | Modify the given extras based on whether font or season titles 525 | are custom. 526 | 527 | Args: 528 | extras: Dictionary to modify. 529 | custom_font: Whether the font are custom. 530 | custom_season_titles: Whether the season titles are custom. 531 | """ 532 | 533 | # Generic font, reset episode text and box colors 534 | if not custom_font: 535 | if 'episode_text_color' in extras: 536 | extras['episode_text_color'] =\ 537 | TintedFramePlusTitleCard.EPISODE_TEXT_COLOR 538 | if 'episode_text_font' in extras: 539 | extras['episode_text_font'] =\ 540 | TintedFramePlusTitleCard.EPISODE_TEXT_FONT 541 | if 'episode_text_font_size' in extras: 542 | extras['episode_text_font_size'] = 1.0 543 | if 'episode_text_vertical_shift' in extras: 544 | extras['episode_text_vertical_shift'] = 0 545 | if 'frame_color' in extras: 546 | extras['frame_color'] = TintedFramePlusTitleCard.TITLE_COLOR 547 | 548 | 549 | @staticmethod 550 | def is_custom_font(font: 'Font') -> bool: # type: ignore 551 | """ 552 | Determine whether the given font characteristics constitute a 553 | default or custom font. 554 | 555 | Args: 556 | font: The Font being evaluated. 557 | 558 | Returns: 559 | True if a custom font is indicated, False otherwise. 560 | """ 561 | 562 | return ((font.color != TintedFramePlusTitleCard.TITLE_COLOR) 563 | or (font.file != TintedFramePlusTitleCard.TITLE_FONT) 564 | or (font.interline_spacing != 0) 565 | or (font.interword_spacing != 0) 566 | or (font.kerning != 1.0) 567 | or (font.size != 1.0) 568 | or (font.vertical_shift != 0) 569 | ) 570 | 571 | @property 572 | def black_title_commands(self) -> ImageMagickCommands: 573 | """ 574 | Subcommands for adding the black stroke behind the title text. 575 | """ 576 | 577 | # Stroke disabled, return empty command 578 | if self.font_stroke_width == 0: 579 | return [] 580 | 581 | stroke_width = 3.0 * self.font_stroke_width 582 | vertical_shift = 245 + self.font_vertical_shift 583 | 584 | return [ 585 | f'-fill "{self.stroke_color}"', 586 | f'-stroke "{self.stroke_color}"', 587 | f'-strokewidth {stroke_width}', 588 | f'-annotate +0+{vertical_shift} "{self.title_text}"', 589 | ] 590 | 591 | @staticmethod 592 | def is_custom_season_titles( 593 | custom_episode_map: bool, 594 | episode_text_format: str, 595 | ) -> bool: 596 | """ 597 | Determine whether the given attributes constitute custom or 598 | generic season titles. 599 | 600 | Args: 601 | custom_episode_map: Whether the EpisodeMap was customized. 602 | episode_text_format: The episode text format in use. 603 | 604 | Returns: 605 | True if custom season titles are indicated, False otherwise. 606 | """ 607 | 608 | standard_etf = TintedFramePlusTitleCard.EPISODE_TEXT_FORMAT.upper() 609 | 610 | return (custom_episode_map 611 | or episode_text_format.upper() != standard_etf) 612 | 613 | 614 | def create(self) -> None: 615 | """ 616 | Make the necessary ImageMagick and system calls to create this 617 | object's defined title card. 618 | """ 619 | # Font customizations 620 | font_size = 157.41 * self.font_size 621 | interline_spacing = -22 + self.font_interline_spacing 622 | interword_spacing = 50 + self.font_interword_spacing 623 | kerning = -1.25 * self.font_kerning 624 | vertical_shift = 245 + self.font_vertical_shift 625 | 626 | # Error and exit if logo is specified and DNE 627 | if ('logo' in (self.top_element, self.middle_element, self.bottom_element) 628 | and (self.logo is None or not self.logo.exists())): 629 | log.error(f'Logo file "{self.logo}" does not exist') 630 | return None 631 | 632 | command = ' '.join([ 633 | f'convert "{self.source_file.resolve()}"', 634 | # Resize and apply styles to source image 635 | *self.resize_and_style, 636 | # Add blurred edges (if indicated) 637 | *self.blur_commands, 638 | # Global title text options 639 | f'-gravity south', 640 | f'-font "{self.font_file}"', 641 | f'-kerning {kerning}', 642 | f'-interword-spacing {interword_spacing}', 643 | f'-interline-spacing {interline_spacing}', 644 | f'-pointsize {font_size}', 645 | # Black stroke behind title text 646 | *self.black_title_commands, 647 | # Title text 648 | f'-fill "{self.font_color}"', 649 | f'-annotate +0+{vertical_shift} "{self.title_text}"', 650 | *self.index_text_commands, 651 | *self.logo_commands, 652 | *self.frame_commands, 653 | # Attempt to overlay mask 654 | *self.mask_commands, 655 | # Create card 656 | *self.resize_output, 657 | f'"{self.output_file.resolve()}"', 658 | ]) 659 | 660 | self.image_magick.run(command) 661 | --------------------------------------------------------------------------------