|
import os |
|
import tempfile |
|
from io import BytesIO |
|
from pathlib import Path |
|
from typing import List, Literal, Optional, Tuple, Union |
|
from urllib.parse import urlparse |
|
|
|
import cairosvg |
|
import numpy as np |
|
import pygments |
|
import requests |
|
from PIL import Image, ImageDraw, ImageFont |
|
from pygments.formatters import ImageFormatter |
|
from pygments.lexers import PythonLexer |
|
from pygments.styles import get_style_by_name |
|
|
|
|
|
def create_gradient_background( |
|
width: int, |
|
height: int, |
|
start_color: Tuple[int, int, int], |
|
end_color: Tuple[int, int, int], |
|
frame_num: int = 0, |
|
) -> Image.Image: |
|
"""Create animated gradient background with wave effects.""" |
|
|
|
x = np.arange(width) |
|
y = np.arange(height) |
|
X, Y = np.meshgrid(x, y) |
|
|
|
wave1 = 30 * np.sin(Y * 0.02 + frame_num * 0.1) |
|
wave2 = 15 * np.sin(Y * 0.03 - frame_num * 0.05) |
|
wave3 = 10 * np.cos(X * 0.02 + frame_num * 0.08) |
|
wave = wave1 + wave2 + wave3 |
|
|
|
|
|
base_progress = Y / height |
|
wave_offset = wave / height |
|
progress = np.clip(base_progress + wave_offset, 0, 1) |
|
|
|
|
|
r = np.clip( |
|
(start_color[0] + (end_color[0] - start_color[0]) * progress), 0, 255 |
|
).astype(np.uint8) |
|
g = np.clip( |
|
(start_color[1] + (end_color[1] - start_color[1]) * progress), 0, 255 |
|
).astype(np.uint8) |
|
b = np.clip( |
|
(start_color[2] + (end_color[2] - start_color[2]) * progress), 0, 255 |
|
).astype(np.uint8) |
|
|
|
|
|
rgb_array = np.stack([r, g, b], axis=2) |
|
return Image.fromarray(rgb_array) |
|
|
|
|
|
def create_window_background( |
|
width: int, |
|
height: int, |
|
style_name: str, |
|
filename: Optional[str] = None, |
|
font_size: int = 24, |
|
) -> Image.Image: |
|
"""Create window background with title bar and control buttons.""" |
|
|
|
style_obj = get_style_by_name(style_name) |
|
bg_color = style_obj.background_color |
|
if bg_color.startswith("#"): |
|
bg_color = tuple(int(bg_color[i : i + 2], 16) for i in (1, 3, 5)) |
|
else: |
|
bg_color = (40, 40, 40) |
|
|
|
window = Image.new("RGBA", (width, height), (0, 0, 0, 0)) |
|
draw = ImageDraw.Draw(window) |
|
|
|
title_bar_height = 40 |
|
radius = 10 |
|
draw.rounded_rectangle([(0, 0), (width, height)], radius, fill=bg_color) |
|
|
|
|
|
circle_y = title_bar_height // 2 |
|
draw.ellipse((20, circle_y - 6, 32, circle_y + 6), fill=(255, 95, 87)) |
|
draw.ellipse((40, circle_y - 6, 52, circle_y + 6), fill=(255, 189, 46)) |
|
draw.ellipse((60, circle_y - 6, 72, circle_y + 6), fill=(39, 201, 63)) |
|
|
|
if filename: |
|
try: |
|
font = ImageFont.truetype("Arial Bold", int(font_size * 0.5)) |
|
except: |
|
font = ImageFont.load_default() |
|
text_width = draw.textlength(filename, font=font) |
|
text_x = (width - text_width) // 2 |
|
draw.text((text_x, circle_y - 6), filename, fill=(200, 200, 200), font=font) |
|
|
|
return window |
|
|
|
|
|
def load_and_resize_image( |
|
image_path: str, target_width: int, window_padding: int |
|
) -> Tuple[Optional[Image.Image], int]: |
|
"""Load and resize an image from path or URL.""" |
|
try: |
|
if urlparse(image_path).scheme in ("http", "https"): |
|
response = requests.get(image_path) |
|
img = Image.open(BytesIO(response.content)) |
|
else: |
|
img = Image.open(image_path) |
|
|
|
|
|
aspect = img.width / img.height |
|
new_width = target_width - 2 * window_padding |
|
new_height = int(new_width / aspect) |
|
img = img.resize((new_width, new_height)) |
|
|
|
|
|
height_with_padding = img.height + 2 * window_padding |
|
padded_img = Image.new( |
|
"RGBA", |
|
(img.width + 2 * window_padding, height_with_padding), |
|
(0, 0, 0, 0), |
|
) |
|
padded_img.paste(img, (window_padding, window_padding)) |
|
|
|
return padded_img, height_with_padding |
|
except Exception as e: |
|
print(f"Warning: Could not load image: {e}") |
|
return None, 0 |
|
|
|
|
|
def create_code_gif( |
|
code: Union[str, Path], |
|
output_file: str | None = None, |
|
style: str = "monokai", |
|
font_size: int = 24, |
|
start_delay: float = 0.5, |
|
end_delay: float = 1.0, |
|
acceleration: float = 0.8, |
|
line_numbers: bool = True, |
|
gradient_start: Tuple[int, int, int] = (45, 49, 66), |
|
gradient_end: Tuple[int, int, int] = (239, 129, 132), |
|
title: Optional[str] = None, |
|
filename: Optional[str] = None, |
|
favicon: Optional[str] = None, |
|
photo: Optional[str] = None, |
|
photo_position: Literal["above", "below"] = "above", |
|
comments: Optional[List[str]] = None, |
|
comments_position: Literal["above", "below"] = "above", |
|
aspect_ratio: float = 16 / 9, |
|
) -> None: |
|
"""Creates an animated GIF of code being typed out with increasing speed.""" |
|
|
|
|
|
min_padding = 20 |
|
window_padding = 20 |
|
title_bar_height = 40 |
|
title_font_size = int(font_size * 2.5) |
|
comment_font_size = int(font_size * 2) |
|
title_height = title_font_size * 2 if title else 0 |
|
comment_height = comment_font_size * 2 if comments else 0 |
|
|
|
|
|
code_str = code.read_text() if isinstance(code, Path) else code |
|
|
|
|
|
lexer = PythonLexer() |
|
formatter = ImageFormatter( |
|
style=style, line_numbers=line_numbers, font_size=font_size |
|
) |
|
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".png") as tmp: |
|
tmp.write(pygments.highlight(code_str, lexer, formatter)) |
|
tmp.flush() |
|
with Image.open(tmp.name) as img: |
|
code_width, code_height = img.size |
|
|
|
total_width = code_width + 2 * window_padding |
|
final_height = code_height + title_bar_height + 2 * window_padding |
|
|
|
|
|
photo_img, photo_height = ( |
|
load_and_resize_image(photo, total_width, window_padding) |
|
if photo |
|
else (None, 0) |
|
) |
|
|
|
|
|
content_height = ( |
|
title_height + photo_height + comment_height + final_height + 4 * min_padding |
|
) |
|
background_width = max( |
|
total_width + 2 * min_padding, int(content_height * aspect_ratio) |
|
) |
|
background_height = content_height |
|
window_x = (background_width - total_width) // 2 |
|
|
|
|
|
logo = None |
|
if favicon: |
|
logo_size = int(min(background_width, background_height) * 0.1) |
|
try: |
|
if favicon.lower().endswith(".svg"): |
|
if urlparse(favicon).scheme in ("http", "https"): |
|
response = requests.get(favicon) |
|
png_data = cairosvg.svg2png( |
|
bytestring=response.content, |
|
output_width=logo_size, |
|
output_height=logo_size, |
|
) |
|
else: |
|
png_data = cairosvg.svg2png( |
|
url=favicon, |
|
output_width=logo_size, |
|
output_height=logo_size, |
|
) |
|
logo = Image.open(BytesIO(png_data)) |
|
else: |
|
logo, _ = load_and_resize_image(favicon, logo_size, 0) |
|
if logo: |
|
logo.thumbnail((logo_size, logo_size)) |
|
except Exception as e: |
|
print(f"Warning: Could not load favicon: {e}") |
|
|
|
|
|
try: |
|
title_font = ImageFont.truetype("Arial Bold", title_font_size) |
|
comment_font = ImageFont.truetype("Arial", comment_font_size) |
|
except: |
|
title_font = comment_font = ImageFont.load_default() |
|
|
|
|
|
frames = [] |
|
with tempfile.TemporaryDirectory() as tmpdir: |
|
code_lines = code_str.split("\n") |
|
num_frames = len(code_lines) |
|
frames_per_comment = num_frames // len(comments) if comments else 0 |
|
|
|
|
|
window = create_window_background( |
|
total_width, final_height, style, filename, font_size |
|
) |
|
|
|
for i in range(num_frames): |
|
current_code = "\n".join(code_lines[: i + 1]) |
|
highlighted = pygments.highlight(current_code, lexer, formatter) |
|
|
|
temp_path = os.path.join(tmpdir, f"frame_{i}.png") |
|
with open(temp_path, "wb") as f: |
|
f.write(highlighted) |
|
|
|
code_img = Image.open(temp_path) |
|
background = create_gradient_background( |
|
background_width, background_height, gradient_start, gradient_end, i |
|
) |
|
|
|
current_y = min_padding |
|
|
|
|
|
if title: |
|
draw = ImageDraw.Draw(background) |
|
text_width = draw.textlength(title, font=title_font) |
|
text_x = (background_width - text_width) // 2 |
|
draw.text( |
|
(text_x, current_y), title, fill=(255, 255, 255), font=title_font |
|
) |
|
current_y += title_height |
|
|
|
|
|
if photo_img and photo_position == "above": |
|
photo_x = (background_width - photo_img.width) // 2 |
|
background.paste(photo_img, (photo_x, current_y), photo_img) |
|
current_y += photo_height |
|
|
|
|
|
if comments and comments_position == "above": |
|
draw = ImageDraw.Draw(background) |
|
comment_idx = min(i // frames_per_comment, len(comments) - 1) |
|
comment = comments[comment_idx] |
|
text_width = draw.textlength(comment, font=comment_font) |
|
text_x = (background_width - text_width) // 2 |
|
draw.text( |
|
(text_x, current_y), |
|
comment, |
|
fill=(255, 255, 255), |
|
font=comment_font, |
|
) |
|
current_y += comment_height |
|
|
|
|
|
window_copy = window.copy() |
|
window_copy.paste( |
|
code_img, (window_padding, window_padding + title_bar_height) |
|
) |
|
background.paste(window_copy, (window_x, current_y), window_copy) |
|
current_y += final_height |
|
|
|
|
|
if photo_img and photo_position == "below": |
|
photo_x = (background_width - photo_img.width) // 2 |
|
current_y += min_padding |
|
background.paste(photo_img, (photo_x, current_y), photo_img) |
|
current_y += photo_height |
|
|
|
|
|
if comments and comments_position == "below": |
|
draw = ImageDraw.Draw(background) |
|
comment_idx = min(i // frames_per_comment, len(comments) - 1) |
|
comment = comments[comment_idx] |
|
text_width = draw.textlength(comment, font=comment_font) |
|
text_x = (background_width - text_width) // 2 |
|
current_y += min_padding |
|
draw.text( |
|
(text_x, current_y), |
|
comment, |
|
fill=(255, 255, 255), |
|
font=comment_font, |
|
) |
|
|
|
|
|
if logo: |
|
logo_x = background_width - logo.width - min_padding |
|
logo_y = background_height - logo.height - min_padding |
|
background.paste( |
|
logo, (logo_x, logo_y), logo if logo.mode == "RGBA" else None |
|
) |
|
|
|
frames.append(background) |
|
|
|
|
|
delays = np.array( |
|
[ |
|
start_delay * (acceleration ** (i / (len(frames) - 1))) |
|
for i in range(len(frames)) |
|
] |
|
) |
|
delays = np.clip(delays, end_delay, None) |
|
|
|
if output_file is not None: |
|
|
|
frames[0].save( |
|
output_file, |
|
save_all=True, |
|
append_images=frames[1:], |
|
duration=[int(d * 1000) for d in delays], |
|
loop=0, |
|
) |
|
return frames |
|
|