gifmaker/gifmaker/media.py

512 lines
14 KiB
Python

# Modules
from .config import config
from . import utils
from . import words
# Libraries
import imageio # type: ignore
import numpy as np
import numpy.typing as npt
from PIL import Image, ImageFilter, ImageOps, ImageDraw, ImageFont # type: ignore
# Standard
import random
from io import BytesIO
from pathlib import Path
from typing import List, Dict, Union
def get_frames() -> List[Image.Image]:
count_frames()
assert isinstance(config.frames, int)
assert isinstance(config.input, Path)
frames = []
path = config.input
ext = utils.get_extension(path)
if (ext == "jpg") or (ext == "jpeg") or (ext == "png"):
reader = imageio.imread(path)
max_frames = 1
mode = "image"
elif ext == "gif":
reader = imageio.mimread(path)
max_frames = len(reader)
mode = "gif"
else:
reader = imageio.get_reader(path)
max_frames = reader.count_frames()
mode = "video"
num_frames = max_frames if config.remake else config.frames
order = "normal" if (config.remake or config.framelist) else config.order
framelist = config.framelist if config.framelist else range(max_frames)
current = 0
# Sometimes it fails to read the frames so it needs more tries
for _ in range(0, num_frames * 25):
if order == "normal":
index = framelist[current]
elif order == "random":
if config.frameopts:
index = random.choice(config.frameopts)
else:
assert isinstance(config.Internal.random_frames, random.Random)
index = config.Internal.random_frames.randint(0, len(framelist))
try:
if mode == "image":
img = reader
elif mode == "video":
img = reader.get_data(index)
elif mode == "gif":
img = reader[index]
frames.append(to_pillow(img))
except Exception as e:
pass
if len(frames) == num_frames:
break
if order == "normal":
current += 1
if current >= len(framelist):
current = 0
if mode == "video":
reader.close()
return frames
def draw_text(frame: Image.Image, line: str) -> Image.Image:
draw = ImageDraw.Draw(frame, "RGBA")
font = config.get_font()
data = get_text_data(frame, line, font)
get_colors = True
if line == config.Internal.last_words:
if config.word_color_mode == "normal":
get_colors = False
if not config.Internal.last_colors:
get_colors = True
if get_colors:
fontcolor = config.get_color("fontcolor")
bgcolor = config.get_color("bgcolor")
ocolor = config.get_color("outline")
else:
fontcolor = config.Internal.last_colors[0]
bgcolor = config.Internal.last_colors[1]
ocolor = config.Internal.last_colors[2]
config.Internal.last_colors = [fontcolor, bgcolor, ocolor]
config.Internal.last_words = line
min_x = data["min_x"]
min_y = data["min_y"]
max_x = data["max_x"]
max_y = data["max_y"]
min_x_p = min_x - config.padding
min_y_p = min_y - config.padding + data["ascender"]
max_x_p = max_x + config.padding
max_y_p = max_y + config.padding
if not config.descender:
max_y_p -= data["descender"]
if config.bgcolor:
alpha = utils.add_alpha(bgcolor, config.opacity)
rect_pos = (min_x_p, min_y_p), (max_x_p, max_y_p)
draw.rounded_rectangle(rect_pos, fill=alpha, radius=config.radius)
if config.outline:
owidth = config.outlinewidth
owidth = utils.divisible(owidth, 2)
halfwidth = owidth / 2
if not config.no_outline_top:
draw.line([(min_x_p, min_y_p - halfwidth),
(max_x_p, min_y_p - halfwidth)], fill=ocolor, width=owidth)
if not config.no_outline_left:
draw.line([(min_x_p - halfwidth, min_y_p - owidth + 1),
(min_x_p - halfwidth, max_y_p + owidth)], fill=ocolor, width=owidth)
if not config.no_outline_bottom:
draw.line([(min_x_p, max_y_p + halfwidth),
(max_x_p, max_y_p + halfwidth)], fill=ocolor, width=owidth)
if not config.no_outline_right:
draw.line([(max_x_p + halfwidth, min_y_p - owidth + 1),
(max_x_p + halfwidth, max_y_p + owidth)], fill=ocolor, width=owidth)
draw.multiline_text((min_x, min_y), line, fill=fontcolor, font=font, align=config.align)
return frame
def get_text_data(frame: Image.Image, line: str, font: ImageFont.FreeTypeFont) -> Dict[str, int]:
draw = ImageDraw.Draw(frame)
width, height = frame.size
p_top = config.top
p_bottom = config.bottom
p_left = config.left
p_right = config.right
b_left, b_top, b_right, b_bottom = draw.multiline_textbbox((0, 0), line, font=font)
ascender = font.getbbox(line.split("\n")[0])[1]
descender = font.getbbox(line.split("\n")[-1], anchor="ls")[3]
# Left
if (p_left is not None) and (p_left >= 0):
text_x = p_left
# Right
elif (p_right is not None) and (p_right >= 0):
text_x = width - b_right - p_right
else:
# Center Horizontal
text_x = (width - b_right) // 2
# Negatives Horizontal
if (p_left is not None) and (p_left < 0):
text_x += p_left
elif (p_right is not None) and (p_right < 0):
text_x -= p_right
# Top
if (p_top is not None) and (p_top >= 0):
text_y = p_top - ascender
# Bottom
elif (p_bottom is not None) and (p_bottom >= 0):
if not config.descender:
text_y = height - b_bottom + descender - p_bottom
else:
text_y = height - b_bottom - p_bottom
else:
# Center Vertical
if not config.descender:
text_y = (height - b_bottom + descender - ascender - (config.padding / 2)) // 2
else:
text_y = (height - b_bottom - ascender) // 2
# Negatives Vertical
if (p_top is not None) and (p_top < 0):
text_y += p_top
elif (p_bottom is not None) and (p_bottom < 0):
text_y -= p_bottom
ans = {
"min_x": text_x,
"min_y": text_y,
"max_x": text_x + b_right,
"max_y": text_y + b_bottom,
"ascender": ascender,
"descender": descender,
}
return ans
def word_frames(frames: List[Image.Image]) -> List[Image.Image]:
if not config.words:
return frames
worded = []
num_words = len(config.words)
for i, frame in enumerate(frames):
if config.fillgen:
line = words.generate(config.words[0], False)[0]
else:
index = i
if index >= num_words:
if config.fillwords:
index = num_words - 1
else:
worded.append(frame)
continue
line = config.words[index]
if line:
frame = draw_text(frame, line)
worded.append(frame)
return worded
def resize_frames(frames: List[Image.Image]) -> List[Image.Image]:
if (not config.width) and (not config.height):
return frames
new_frames = []
new_width = config.width
new_height = config.height
w, h = frames[0].size
ratio = w / h
if new_width and (not new_height):
new_height = int(new_width / ratio)
elif new_height and (not new_width):
new_width = int(new_height * ratio)
assert isinstance(new_width, int)
assert isinstance(new_height, int)
if (new_width <= 0) or (new_height <= 0):
return frames
if config.nogrow:
if (new_width > w) or (new_height > h):
return frames
size = (new_width, new_height)
for frame in frames:
new_frames.append(frame.resize(size))
return new_frames
def render(frames: List[Image.Image]) -> Union[Path, None]:
assert isinstance(config.output, Path)
ext = utils.get_extension(config.output)
fmt = ext if ext else config.format
if config.vertical or config.horizontal:
if fmt not in ["jpg", "png"]:
fmt = "png"
def makedir(path: Path) -> None:
try:
path.mkdir(parents=False, exist_ok=True)
except BaseException:
utils.exit("Failed to make output directory")
return
if ext:
makedir(config.output.parent)
output = config.output
else:
makedir(config.output)
rand = utils.random_string()
file_name = f"{rand}.{config.format}"
output = Path(config.output, file_name)
if config.vertical:
frames = [append_frames(frames, "vertical")]
if config.horizontal:
frames = [append_frames(frames, "horizontal")]
if fmt == "gif":
frames = to_array_all(frames)
loop = None if config.loop <= -1 else config.loop
imageio.mimsave(output, frames, format="GIF", duration=config.delay, loop=loop)
elif fmt == "png":
frame = frames[0]
frame = to_array(frame)
imageio.imsave(output, frame, format="PNG")
elif fmt == "jpg":
frame = frames[0]
frame = frame.convert("RGB")
frame = to_array(frame)
imageio.imsave(output, frame, format="JPEG")
elif fmt == "mp4" or fmt == "webm":
frames = to_array_all(frames)
fps = 1000 / config.delay
if fmt == "mp4":
codec = "libx264"
elif fmt == "webm":
codec = "libvpx"
writer = imageio.get_writer(output, fps=fps, codec=codec)
for frame in frames:
writer.append_data(frame)
writer.close()
else:
utils.exit("Invalid format")
return None
return output
def apply_filters(frames: List[Image.Image]) -> List[Image.Image]:
if (config.filter == "none") and (not config.filterlist):
return frames
new_frames = []
min_hue = 1
max_hue = 8
hue_step = 20
hue_filters = [f"hue{i}" for i in range(min_hue, max_hue + 1)]
all_filters = hue_filters + ["gray", "blur", "invert", "none"]
filters = []
def get_filters() -> None:
nonlocal filters
if config.filteropts:
filters = config.filteropts.copy()
elif config.filter.startswith("anyhue"):
filters = hue_filters.copy()
else:
filters = all_filters.copy()
def random_filter() -> str:
assert isinstance(config.Internal.random_filters, random.Random)
filtr = config.Internal.random_filters.choice(filters)
if not config.repeatfilter:
remove_filter(filtr)
return filtr
def remove_filter(filtr: str) -> None:
if filtr in filters:
filters.remove(filtr)
if not filters:
get_filters()
def change_hue(frame: Image.Image, n: int) -> Image.Image:
hsv = frame.convert("HSV")
h, s, v = hsv.split()
h = h.point(lambda i: (i + hue_step * n) % 180)
new_frame = Image.merge("HSV", (h, s, v))
if frame.mode in ["RGBA", "LA"]:
new_frame = Image.merge("RGBA", (new_frame.split() + (frame.split()[3],)))
else:
new_frame = new_frame.convert("RGB")
return new_frame
get_filters()
filtr = config.filter
if not config.filterlist:
if config.filter == "random" or config.filter == "anyhue":
filtr = random_filter()
for frame in frames:
if config.filterlist:
filtr = config.filterlist.pop(0)
elif config.filter == "random2" or config.filter == "anyhue2":
filtr = random_filter()
new_frame = None
if filtr.startswith("hue"):
for n in range(min_hue, max_hue + 1):
if filtr == f"hue{n}":
new_frame = change_hue(frame, n)
break
if new_frame is None:
if filtr in ["gray", "grey"]:
if frame.mode in ["RGBA", "LA"]:
r, g, b, a = frame.split()
gray_img = ImageOps.grayscale(frame.convert("RGB"))
rgb_gray = ImageOps.colorize(gray_img, "black", "white")
new_frame = Image.merge("RGBA", (rgb_gray.split() + (a,)))
else:
new_frame = ImageOps.colorize(frame.convert("L"), "black", "white")
elif filtr == "blur":
new_frame = frame.filter(ImageFilter.BLUR)
elif filtr == "invert":
new_frame = ImageOps.invert(frame.convert("RGB"))
else:
new_frame = frame
new_frames.append(new_frame)
return new_frames
def count_frames() -> None:
if config.frames is not None:
return
if config.framelist:
config.frames = len(config.framelist)
elif config.words:
num_words = len(config.words)
config.frames = num_words if num_words > 0 else config.frames
else:
config.frames = 3
def rgb_or_rgba(array: npt.NDArray[np.float64]) -> str:
if array.shape[2] == 4:
return "RGBA"
else:
return "RGB"
def to_pillow(array: npt.NDArray[np.float64]) -> Image.Image:
mode = rgb_or_rgba(array)
return Image.fromarray(array, mode=mode)
def to_array(frame: Image.Image) -> npt.NDArray[np.float64]:
return np.array(frame)
def to_array_all(frames: List[Image.Image]) -> List[npt.NDArray[np.float64]]:
return [to_array(frame) for frame in frames]
def deep_fry(frames: List[Image.Image]) -> List[Image.Image]:
if not config.deepfry:
return frames
quality = 3
new_frames = []
for frame in frames:
stream = BytesIO()
frame = frame.convert("RGB")
frame.save(stream, format="JPEG", quality=quality)
new_frames.append(Image.open(stream))
return new_frames
def append_frames(frames: List[Image.Image], mode: str) -> Image.Image:
widths, heights = zip(*(i.size for i in frames))
if mode == "vertical":
total_width = max(widths)
total_height = sum(heights)
elif mode == "horizontal":
total_width = sum(widths)
total_height = max(heights)
new_frame = Image.new("RGB", (total_width, total_height))
offset = 0
for frame in frames:
if mode == "vertical":
new_frame.paste(frame, (0, offset))
offset += frame.size[1]
elif mode == "horizontal":
new_frame.paste(frame, (offset, 0))
offset += frame.size[0]
return new_frame