Browse Source

added new fake Discord screenshot generator

Other memes:
- SoyPhone

Utility functions added:
- Square aspect ratio cropper (square_crop)
- Circle profile pic maker (circle_pfp)
- PIL alpha_composite helper (alpha_comp_prep)
- Text wrapping and bounding helpers
- Tuple adder (tuple_add)
- iPhone timestamp generator for top bar (get_iphone_time_s)

Also added:
- Meme abstract base class (in pseudbot/meme/abc.py)
- Post styling information (in pseudbot/meme/post_styles.py)
pull/1/head
Albert Sanchez 2 months ago
parent
commit
16dff8fca3
  1. 100
      examples/post_meme.py
  2. BIN
      font/Cantarell-VF.otf
  3. BIN
      font/clacon.ttf
  4. BIN
      img/pseud-error.png
  5. BIN
      media/bonk/E4chSR5WQAQ_Hn3.jpg
  6. BIN
      media/phew/EyeJ-4nWEAAFybq.jpg
  7. 0
      pseudbot/meme/__init__.py
  8. 145
      pseudbot/meme/abc.py
  9. 184
      pseudbot/meme/phone_screenshot.py
  10. 126
      pseudbot/meme/post.py
  11. 81
      pseudbot/meme/post_styles.py
  12. 35
      pseudbot/meme/soyphone.py
  13. 4
      pseudbot/pastas.py
  14. 82
      pseudbot/util.py
  15. 1
      requirements.txt
  16. 2
      setup.py
  17. BIN
      templates/discord-phone/discord-heading-notxt-no-notification-notime.png
  18. BIN
      templates/discord-phone/discord-heading-notxt.png
  19. BIN
      templates/discord-phone/discord-iphone-bottom-bar.png
  20. BIN
      templates/iphone/icons/notification/grindr.png
  21. BIN
      templates/iphone/iphone-noti-grindr-text.png
  22. BIN
      templates/soyphone/soyphone-13.png

100
examples/post_meme.py

@ -0,0 +1,100 @@
"""
Test of Discord fake post pixmap generator.
"""
from PIL import Image
from pseudbot.meme.post import Post
from pseudbot.meme.phone_screenshot import PhoneScreenshot
from pseudbot.meme.soyphone import SoyPhone
tt = {
"channel_name": "staff-feet-pics",
"posts": [
{
"screen_name": "Owen",
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg",
"text": "I walk up 4 flights of stairs every single day",
"timestamp": "Today at 5:07 AM",
},
{
"screen_name": "Owen",
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg",
"text": "I walk up 4 flights of stairs every single day",
"timestamp": "Today at 5:07 AM",
},
{
"screen_name": "Owen",
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg",
"text": "I walk up 4 flights of stairs every single day",
"timestamp": "Today at 5:07 AM",
},
{
"screen_name": "Owen",
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg",
"text": "I walk up 4 flights of stairs every single day",
"timestamp": "Today at 5:07 AM",
},
{
"screen_name": "Owen",
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg",
"text": "I walk up 4 flights of stairs every single day",
"timestamp": "Today at 5:07 AM",
},
{
"screen_name": "Owen",
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg",
"text": "I walk up 4 flights of stairs every single day",
"timestamp": "Today at 5:07 AM",
},
{
"screen_name": "Owen",
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg",
"text": "I walk up 4 flights of stairs every single day",
"timestamp": "Today at 5:07 AM",
},
{
"screen_name": "Owen",
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg",
"text": "I walk up 4 flights of stairs every single day",
"timestamp": "Today at 5:07 AM",
},
{
"screen_name": "Owen",
"pfp": "media/bonk/E4chSR5WQAQ_Hn3.jpg",
"text": "it has an orange tip",
"timestamp": "Today at 5:07 AM",
},
{
"screen_name": "Owen",
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg",
"text": "go look in the fields for it",
"timestamp": "Today at 5:07 AM",
},
],
}
bt = "Owen #1"
# p = Post(top_text=tt, bottom_text=bt, style="discord")
# pi = p.get_pixmap()
# pi.show()
# This Post() generates an error pixmap
# q = Post(top_text={}, bottom_text=bt, style="discord")
# qi = q.get_pixmap()
# qi.show()
# Makes a fake Discord iPhone screenshot
r = PhoneScreenshot(top_text=tt, bottom_text=bt)
ri = r.get_pixmap()
# ri.show()
# Puts the above screenshot pixmap in an iPhone
s = SoyPhone(screenshot=ri)
si = s.get_pixmap()
si.show()
# Puts an arbitrary pixmap in an iPhone
# i = Image.open("media/phew/EyeJ-4nWEAAFybq.jpg").convert("RGBA")
# t = SoyPhone(screenshot=i)
# ti = t.get_pixmap()
# ti.show()

BIN
font/Cantarell-VF.otf

Binary file not shown.

BIN
font/clacon.ttf

Binary file not shown.

BIN
img/pseud-error.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
media/bonk/E4chSR5WQAQ_Hn3.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
media/phew/EyeJ-4nWEAAFybq.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

0
pseudbot/meme/__init__.py

145
pseudbot/meme/abc.py

@ -0,0 +1,145 @@
from abc import ABCMeta, abstractmethod
from PIL import Image, ImageDraw, ImageFont
import typing
from pseudbot.pastas import concat_pasta
from pseudbot.util import styled_wrap, text_wrap
class Meme(metaclass=ABCMeta):
fonts = {
"old_console": "font/clacon.ttf",
"phone_sans": "font/Cantarell-VF.otf",
}
styles = {}
style_methods = {}
offsets = {}
error = False
image = None
reason = ""
post = None
posts = None
def __init__(
self,
top_text: dict = None,
bottom_text: str = None,
):
self.top_text = top_text
self.bottom_text = bottom_text
def init_style_posts(self):
self.check_posts()
self.check_style()
def check_posts(self):
if not "posts" in self.top_text or len(self.top_text["posts"]) < 1:
self.error = True
self.reason = 'No "posts" in top_text!'
else:
self.posts = self.top_text["posts"]
self.post = self.top_text["posts"][0]
if not "screen_name" in self.post:
self.post["screen_name"] = ""
if not "text" in self.post:
self.post["text"] = concat_pasta()
if not "timestamp" in self.post:
self.post["timestamp"] = "Today at time"
def check_style(self):
if self.style not in self.styles:
self.error = True
self.reason = "Invalid style!"
elif self.style not in self.style_methods:
self.error = True
self.reason = "Style not implemented!"
@abstractmethod
def help(self) -> str:
"""
User-callable help for meme generator.
"""
pass
@abstractmethod
def mk_pixmap(self):
pass
def get_styles(self) -> [str]:
return self.styles
def help(self) -> str:
"""
User-callable help for meme generator.
"""
return "TODO"
def get_text(self) -> tuple:
return (self.top_text, self.bottom_text)
def get_pixmap(self):
if self.image is None:
if self.error is True:
return self.error_pixmap(reason=self.reason)
else:
self.mk_pixmap()
return self.image
def draw_styled_text(
self, img: Image, text: str, style: dict, offset: tuple = None
) -> Image:
if offset is None:
offset = style["offset"]
if "wrap" in style:
text = styled_wrap(text, style["wrap"])
align = style["align"] if "align" in style else "left"
image = self.draw_text(
img=img,
text=text,
offset=offset,
size=style["size"],
fill=style["color"],
variation=style["style"],
fontname=style["font"],
align=align,
)
return image
def draw_text(
self,
img: Image,
text: str,
offset: tuple,
size: int,
fill: tuple,
variation: str = "Regular",
fontname: str = "phone_sans",
align="left",
) -> Image:
font = ImageFont.truetype(self.fonts[fontname], size)
font.set_variation_by_name(variation)
d = ImageDraw.Draw(img)
d.text(xy=offset, text=text, fill=fill, font=font, align=align)
return img
def error_pixmap(self, reason: str) -> Image:
efont = ImageFont.truetype("font/clacon.ttf", 70)
reason = text_wrap(text=reason, width=16, height=5)
with Image.open("img/pseud-error.png").convert("RGBA") as base_img:
text_img = Image.new("RGBA", base_img.size, (255, 255, 255, 0))
d = ImageDraw.Draw(text_img)
d.text((292, 466), reason, font=efont, fill=(0, 0, 0, 255))
out = Image.alpha_composite(base_img, text_img)
return out

184
pseudbot/meme/phone_screenshot.py

@ -0,0 +1,184 @@
from .abc import Meme
from PIL import Image, ImageDraw, ImageFont
import typing
from pseudbot.meme.post import Post
from pseudbot.util import (
alpha_comp_prep,
circle_pfp,
get_iphone_time_s,
text_wrap,
tuple_add,
)
class PhoneScreenshot(Meme):
from .post_styles import styles, time_style
def __init__(
self,
top_text: dict = None,
bottom_text: str = None,
style: str = "discord",
width: int = 1200,
height: int = 2340,
notification: str = None,
):
self.top_text = top_text
self.bottom_text = bottom_text
self.style = style
self.width = width
self.height = height
self.notification = notification
self.style_methods = {"discord": self.mk_pixmap}
self.init_style_posts()
if self.notification is not None:
self.notification_style = {
"offset": (102, 91),
"no_text_img": "templates/iphone/iphone-noti-grindr-text.png",
"icon": {
"offset": (26, 34),
"size": (62, 62),
"files": {
"grindr": "templates/iphone/icons/notification/grindr.png"
},
},
"text": {
"app_name": {
"font": "phone_sans",
"style": "Regular",
"size": 35,
"offset": (106, 53),
},
"heading": {
"font": "phone_sans",
"style": "Regular",
"size": 35,
"offset": (34, 128),
},
"body": {
"font": "phone_sans",
"style": "Regular",
"size": 35,
"offset": (34, 181),
},
},
}
def overlay_noti(base_img: Image) -> Image:
return base_img
def mk_bars(self) -> Image:
base_img = Image.new("RGBA", (self.width, self.height), (0, 0, 0, 0))
for bar in self.styles[self.style]["bars"]:
is_top = False
is_bottom = False
do_time = False
bg_img_path = bar["bg_img"]
if "top" in bar:
if bar["top"] is True:
is_top = True
do_time = True
if self.notification is not None:
do_time = False
if "alt_bg_img" in bar:
bg_img_path = self.bar["alt_bg_img"]
if "bottom" in bar:
if bar["bottom"] is True:
is_bottom = True
with Image.open(bg_img_path).convert("RGBA") as _bar_img:
with alpha_comp_prep(
Image.open(bg_img_path).convert("RGBA"),
size=base_img.size,
offset=bar["offset"],
) as bar_img:
if "text" in bar:
try:
if bar["text"]["content"] in self.top_text:
if "format" in bar["text"]:
bar_text = bar["text"]["format"].replace(
"%%%",
self.top_text[bar["text"]["content"]],
)
else:
bar_text = self.top_text[
bar["text"]["content"]
]
else:
bar_text = bar["text"]["default"]
except KeyError:
bar_text = "default text"
bar_img = self.draw_text(
img=bar_img,
text=bar_text,
offset=tuple_add(
bar["text"]["offset"], bar["offset"]
),
size=bar["text"]["size"],
fill=bar["text"]["color"],
variation=bar["text"]["style"],
fontname=bar["text"]["font"],
)
if do_time is True:
bar_img = self.draw_text(
img=bar_img,
text=get_iphone_time_s(),
offset=self.time_style["offset"],
size=self.time_style["size"],
fill=self.time_style["fill"][self.style],
variation=self.time_style["style"],
fontname=self.time_style["font"],
)
base_img = Image.alpha_composite(base_img, bar_img)
if is_bottom is True:
self.bottom_y_offset = 0 - _bar_img.size[1]
if self.notification is not None:
base_img = self.overlay_noti(base_img)
return base_img
def arrange_posts(self) -> Image:
post_base = Image.new(
"RGBA",
(self.width, self.height),
self.styles[self.style]["background"],
)
x_offset = 33
y_offset = self.height
if hasattr(self, "bottom_y_offset"):
y_offset = y_offset + self.bottom_y_offset
for post in reversed(self.posts):
p = Post(
top_text={"posts": [post]},
bottom_text=self.bottom_text,
style=self.style,
)
post_img = p.get_pixmap()
y_offset = y_offset - post_img.size[1]
post_base.paste(post_img, box=(x_offset, y_offset))
if y_offset < 0:
break
return post_base
def mk_pixmap(self):
bars = self.mk_bars() # Must be done before making posts!
posts = self.arrange_posts()
self.image = Image.alpha_composite(posts, bars)

126
pseudbot/meme/post.py

@ -0,0 +1,126 @@
from .abc import Meme
from PIL import Image, ImageDraw, ImageFont
import typing
from pseudbot.util import (
_text_wrap,
alpha_comp_prep,
circle_pfp,
styled_wrap,
text_wrap,
tuple_add,
)
class Post(Meme):
from .post_styles import styles
def __init__(
self,
top_text: dict = None,
bottom_text: str = None,
style: str = "discord",
):
self.top_text = top_text
self.bottom_text = bottom_text
self.style = style
self.style_methods = {"discord": self.draw_discord_post}
self.init_style_posts()
def get_post_dimens(self) -> int:
max_dimens = (0, 0)
offsets_add = {}
post_items = dict(
sorted(
self.styles[self.style]["fields"].items(),
key=lambda s: s[1]["offset"][1],
)
)
while len(post_items) > 0:
items = [k for k in post_items.keys()]
for k in items:
offset_add = (0, 0)
if "offset_add" in post_items[k]:
if k in offsets_add:
offset_add = offsets_add[k]
else:
continue
if k not in self.post:
if "default" in post_items[k]:
self.post[k] = post_items[k]["default"]
else:
self.post[k] = "default text"
wrap = post_items[k]["wrap"]
lines = _text_wrap(self.post[k], wrap[0], wrap[1])
font = ImageFont.truetype(
self.fonts[post_items[k]["font"]], post_items[k]["size"]
)
size = font.getsize("\n".join(lines))
if len(lines) > 1:
size = (size[0], size[1] * len(lines))
offset = tuple_add(post_items[k]["offset"], offset_add)
_max_dimens = tuple_add(offset, size)
max_dimens = (
max(_max_dimens[0], max_dimens[0]),
max(_max_dimens[1], max_dimens[1]),
)
if "offset_for" in post_items[k]:
for dependent in post_items[k]["offset_for"]:
add_x = 0
add_y = 0
if "x" in post_items[k]["offset_for"][dependent]:
add_x = _max_dimens[0]
if "y" in post_items[k]["offset_for"][dependent]:
add_y = _max_dimens[1]
offsets_add[dependent] = (add_x, add_y)
self.offsets[k] = offset
post_items.pop(k)
max_dimens = tuple_add(max_dimens, self.styles[self.style]["margin"])
return max_dimens
def draw_post(self) -> Image:
pass
def draw_discord_post(self) -> Image:
pfp_size = 132
if self.error is True:
return self.error_pixmap(self.reason)
else:
dimens = self.get_post_dimens()
post_base = Image.new(
"RGBA", dimens, self.styles[self.style]["background"]
)
# Draw circle profile pic
with alpha_comp_prep(
circle_pfp(
Image.open(self.post["pfp"]).convert("RGBA"),
size=pfp_size,
),
size=post_base.size,
) as pfp:
post_base = Image.alpha_composite(post_base, pfp)
for field in self.styles[self.style]["fields"].keys():
post_base = self.draw_styled_text(
img=post_base,
text=self.post[field],
style=self.styles[self.style]["fields"][field],
offset=self.offsets[field],
)
return post_base
def mk_pixmap(self):
self.image = self.style_methods[self.style]()

81
pseudbot/meme/post_styles.py

@ -0,0 +1,81 @@
styles = {
"discord": {
"margin": (0, 43),
"fields": {
"screen_name": {
"font": "phone_sans",
"style": "Extra Bold",
"offset": (169, 0),
"size": 59,
"color": (255, 255, 255, 255),
"default": "XxX_HungDaddy_XxX_69",
"offset_for": {
"timestamp": "x",
"text": "y",
},
"wrap": (30, 1),
},
"text": {
"font": "phone_sans",
"style": "Regular",
"offset": (169, 2),
"offset_add": True,
"size": 59,
"color": (255, 255, 255, 255),
"wrap": (40, 20),
},
"timestamp": {
"font": "phone_sans",
"style": "Regular",
"offset": (66, 17),
"offset_add": True,
"size": 35,
"color": (112, 115, 122, 255),
"default": "Today at 5:07 AM",
"wrap": (30, 1),
},
},
"background": (54, 57, 64, 255),
"bars": [
{
"top": True,
"bg_img": "templates/discord-phone/discord-heading-notxt-no-notification-notime.png",
"alt_bg_img": "templates/discord-phone/discord-heading-notxt.png",
"offset": (0, 0),
"text": {
"font": "phone_sans",
"style": "Bold",
"offset": (286, 174),
"size": 58,
"color": (255, 255, 255, 255),
"content": "channel_name",
"default": "chat",
},
},
{
"bottom": True,
"bg_img": "templates/discord-phone/discord-iphone-bottom-bar.png",
"offset": (0, 2080),
"text": {
"font": "phone_sans",
"style": "Regular",
"offset": (380, 62),
"size": 49,
"color": (112, 115, 122, 255),
"format": "Message #%%%",
"content": "channel_name",
"default": "Message #chat",
},
},
],
},
"twitter": {},
}
time_style = {
"size": 54,
"offset": (48, 38),
"font": "phone_sans",
"style": "Bold",
"fill": {"discord": (255, 255, 255, 255)},
}

35
pseudbot/meme/soyphone.py

@ -0,0 +1,35 @@
from .abc import Meme
from PIL import Image
import typing
from pseudbot.util import alpha_comp_prep
class SoyPhone(Meme):
def __init__(
self,
screenshot: Image = None,
):
self.screenshot = screenshot
if self.screenshot is None:
self.error = True
self.reason = 'No "screenshot" provided for iPhone!'
def mk_pixmap(self):
with Image.open("templates/soyphone/soyphone-13.png").convert(
"RGBA"
) as overlay_img:
self.image = Image.new("RGBA", overlay_img.size, (0, 0, 0, 255))
self.screenshot = self.screenshot.resize(
(378, 804), resample=Image.BICUBIC
)
self.screenshot = self.screenshot.rotate(
4.8, resample=Image.BICUBIC, expand=True
)
self.screenshot = alpha_comp_prep(
self.screenshot, size=overlay_img.size, offset=(27, 32)
)
overlay_img = Image.alpha_composite(self.screenshot, overlay_img)
self.image = Image.alpha_composite(self.image, overlay_img)

4
pseudbot/pastas.py

@ -13,3 +13,7 @@ def get_pastas() -> list:
PASTAS = get_pastas()
def concat_pasta(sep: str = " "):
return sep.join(random.choice(PASTAS))

82
pseudbot/util.py

@ -1,11 +1,20 @@
import inspect
import json as j
from os.path import basename
from PIL import Image, ImageDraw
import re
import requests
from time import time
from textwrap import shorten, wrap
from time import localtime, strftime, time
import typing
def get_iphone_time_s() -> str:
ltime = localtime()
hour = re.sub("^0", " ", strftime("%I", ltime))
return hour + ":" + strftime("%M", ltime)
def get_timestamp_s() -> str:
return "🕑" + str(int(time()))
@ -50,6 +59,28 @@ def log_t_by_sname(tweet):
)
def _text_wrap(text: str, width: int, height: int = None):
lines = wrap(text, width=width)
if height is not None:
if len(lines) > height:
lines[height - 1] = shorten(
lines[height - 1] + " " + lines[height],
width=width,
placeholder="...",
)
lines = lines[:height]
return lines
def styled_wrap(text: str, wrap: tuple):
return "\n".join(_text_wrap(text, wrap[0], wrap[1]))
def text_wrap(text: str, width: int, height: int = None):
return "\n".join(_text_wrap(text, width, height))
def download_tweet_media(tweet: dict):
if "extended_entities" in tweet:
try:
@ -66,3 +97,52 @@ def download_tweet_media(tweet: dict):
with open(filename, mode="wb") as f:
for chunk in r.iter_content(1024):
f.write(chunk)
def square_crop(image: Image) -> Image:
(width, height) = image.size
if width != height:
new_image = None
if width > height:
return image.crop((0, 0, height, height))
else:
return image.crop((0, 0, width, width))
else:
return image
def alpha_comp_prep(
image: Image, size: tuple, offset: tuple = (0, 0), scale: tuple = None
):
canvas = Image.new("RGBA", size, (0, 0, 0, 0))
if scale is not None:
image = image.resize(size=scale, resample=Image.BICUBIC)
canvas.paste(image, box=offset)
return canvas
def circle_mask(size: int):
ci = Image.new("L", (size, size), 0)
d = ImageDraw.Draw(ci)
d.ellipse(xy=(0, 0, size, size), fill=255)
return ci
def circle_pfp(pfp: Image, size: int = None) -> Image:
pfp = square_crop(pfp)
if size is not None:
pfp = pfp.resize((size, size), resample=Image.BICUBIC)
(width, height) = pfp.size
pfp.putalpha(circle_mask(width))
return pfp
def tuple_add(a: tuple, b: tuple) -> tuple:
return tuple(map(sum, zip(a, b)))

1
requirements.txt

@ -1,3 +1,4 @@
filetype
Pillow
tweepy
requests[socks]

2
setup.py

@ -17,7 +17,7 @@ setup(
author="Albert Sanchez",
description="Rick and Morty copypasta bot",
author_email="singletons@goat.si",
packages=["pseudbot"],
packages=["pseudbot", "pseudbot.meme"],
include_package_data=True,
scripts=scripts,
)

BIN
templates/discord-phone/discord-heading-notxt-no-notification-notime.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
templates/discord-phone/discord-heading-notxt.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
templates/discord-phone/discord-iphone-bottom-bar.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
templates/iphone/icons/notification/grindr.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
templates/iphone/iphone-noti-grindr-text.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
templates/soyphone/soyphone-13.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Loading…
Cancel
Save