├── .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 | 
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 |
--------------------------------------------------------------------------------