prole/img/generate_loading_gif.py

346 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Generate an animated loading GIF for Prole installer.
Inspired by 1980s Electronic Arts floppy disk loaders with alternating
animation speeds to reflect installation progress.
"""
import os
import argparse
from PIL import Image, ImageDraw, ImageFont
import math
import random
# Default Configuration
DEFAULT_OUTPUT_SIZE = (1024, 768)
DEFAULT_FRAMES = 60
DEFAULT_ENERGY_FRAMES = 8
DEFAULT_MICE_FRAMES = 30
DEFAULT_COLORS = 256
DEFAULT_OUTPUT_FILE = "proleLoading.gif"
# Colors (blueprint theme)
BLUE_DARK = (30, 60, 120)
BLUE_MEDIUM = (60, 120, 200)
BLUE_LIGHT = (100, 150, 220)
ENERGY_BRIGHT = (150, 200, 255)
ENERGY_CRACKLE = (200, 230, 255)
BG_COLOR = (245, 248, 252)
def draw_mouse(draw, x, y, size=15, angle=0, note_taking=False):
"""Draw a simple mouse figure (Douglas Adams style)"""
# Mouse body (oval)
body_width = size
body_height = size * 0.7
body_bbox = [
x - body_width // 2,
y - body_height // 2,
x + body_width // 2,
y + body_height // 2
]
draw.ellipse(body_bbox, fill=BLUE_DARK, outline=BLUE_MEDIUM, width=1)
# Mouse head
head_size = size * 0.5
head_x = x + int(math.cos(angle) * body_width * 0.3)
head_y = y - int(math.sin(angle) * body_width * 0.3)
head_bbox = [
head_x - head_size // 2,
head_y - head_size // 2,
head_x + head_size // 2,
head_y + head_size // 2
]
draw.ellipse(head_bbox, fill=BLUE_DARK, outline=BLUE_MEDIUM, width=1)
# Ears
ear_size = size * 0.3
ear1_x = head_x - head_size * 0.3
ear1_y = head_y - head_size * 0.3
ear2_x = head_x + head_size * 0.3
ear2_y = head_y - head_size * 0.3
draw.ellipse([ear1_x - ear_size//2, ear1_y - ear_size//2,
ear1_x + ear_size//2, ear1_y + ear_size//2],
fill=BLUE_MEDIUM, outline=BLUE_DARK, width=1)
draw.ellipse([ear2_x - ear_size//2, ear2_y - ear_size//2,
ear2_x + ear_size//2, ear2_y + ear_size//2],
fill=BLUE_MEDIUM, outline=BLUE_DARK, width=1)
# Tail
tail_points = [
(x - body_width * 0.4, y),
(x - body_width * 0.6, y + size * 0.3),
(x - body_width * 0.8, y + size * 0.1)
]
draw.line(tail_points, fill=BLUE_DARK, width=2)
# If taking notes, draw a clipboard/notepad
if note_taking:
clipboard_x = x + body_width * 0.4
clipboard_y = y - size * 0.2
clipboard_size = size * 0.6
# Clipboard
draw.rectangle(
[clipboard_x - clipboard_size//2, clipboard_y - clipboard_size//2,
clipboard_x + clipboard_size//2, clipboard_y + clipboard_size//2],
fill=(250, 250, 245), outline=BLUE_DARK, width=1
)
# Lines on clipboard
for i in range(3):
line_y = clipboard_y - clipboard_size//3 + i * (clipboard_size//3)
draw.line(
[clipboard_x - clipboard_size//3, line_y,
clipboard_x + clipboard_size//3, line_y],
fill=BLUE_MEDIUM, width=1
)
# Pencil/pen
pencil_x = clipboard_x + clipboard_size * 0.3
pencil_y = clipboard_y
draw.line(
[pencil_x, pencil_y - clipboard_size//2,
pencil_x, pencil_y + clipboard_size//2],
fill=BLUE_DARK, width=2
)
def draw_energy_crackle(draw, start_x, start_y, end_x, end_y, intensity, energy_frame, seed=None):
"""Draw crackling energy along a field line"""
if seed is not None:
random.seed(seed + energy_frame) # Consistent randomness per field line
# Calculate points along the curve (field line) - curved path
num_points = 25
points = []
for i in range(num_points + 1):
t = i / num_points
# Create a curved path (field line) - more pronounced curve
curve_amount = 40 * math.sin(t * math.pi)
x = (1 - t) * start_x + t * end_x
y = (1 - t) * start_y + t * end_y + curve_amount
points.append((x, y))
# Draw crackling energy along the line
crackle_intensity = intensity * (0.4 + 0.6 * abs(math.sin(energy_frame * math.pi * 2 / DEFAULT_ENERGY_FRAMES)))
# Draw main energy path with varying intensity
for i in range(len(points) - 1):
p1 = points[i]
p2 = points[i + 1]
# Vary line width based on intensity
line_width = max(1, int(1 + crackle_intensity * 2))
energy_color = tuple(int(c * (0.7 + 0.3 * crackle_intensity)) for c in ENERGY_BRIGHT)
# Main energy line
draw.line([p1, p2], fill=energy_color, width=line_width)
# Add crackling branches (more frequent when intensity is high)
if random.random() < crackle_intensity * 0.6:
branch_length = 4 + random.random() * 12
branch_angle = random.random() * math.pi * 2
branch_start_x = (p1[0] + p2[0]) / 2
branch_start_y = (p1[1] + p2[1]) / 2
branch_end_x = branch_start_x + math.cos(branch_angle) * branch_length
branch_end_y = branch_start_y + math.sin(branch_angle) * branch_length
draw.line(
[branch_start_x, branch_start_y,
branch_end_x, branch_end_y],
fill=ENERGY_CRACKLE, width=1
)
# Small spark at end
spark_size = 1 + int(random.random() * 2)
draw.ellipse(
[branch_end_x - spark_size, branch_end_y - spark_size,
branch_end_x + spark_size, branch_end_y + spark_size],
fill=ENERGY_CRACKLE
)
def generate_frame(base_image, frame_num, energy_frame, mice_frame, mice_frames=30):
"""Generate a single animation frame"""
# Create a copy of the base image
frame = base_image.copy()
draw = ImageDraw.Draw(frame)
# Get image dimensions
width, height = frame.size
center_x, center_y = width // 2, height // 2
# Draw energy crackling on field lines
# Top field lines (curved lines from top pole) - matching logo structure
top_pole_x = center_x
top_pole_y = center_y - 180 # Approximate top pole position
# Create several field lines from top pole (curved outward)
num_top_lines = 7
for i in range(num_top_lines):
angle = (i - num_top_lines // 2) * 0.35 # Spread out symmetrically
line_length = 140 + (i % 2) * 20 # Vary length
end_x = center_x + math.cos(angle) * line_length
end_y = top_pole_y + 80 + math.sin(angle) * 40
intensity = 0.4 + (i % 3) * 0.2 # Vary intensity
draw_energy_crackle(draw, top_pole_x, top_pole_y, end_x, end_y,
intensity, energy_frame, seed=i)
# Bottom field lines
bottom_pole_x = center_x
bottom_pole_y = center_y + 180 # Approximate bottom pole position
num_bottom_lines = 7
for i in range(num_bottom_lines):
angle = (i - num_bottom_lines // 2) * 0.35
line_length = 140 + (i % 2) * 20
end_x = center_x + math.cos(angle) * line_length
end_y = bottom_pole_y - 80 - math.sin(angle) * 40
intensity = 0.4 + (i % 3) * 0.2
draw_energy_crackle(draw, bottom_pole_x, bottom_pole_y, end_x, end_y,
intensity, energy_frame, seed=i + 100)
# Draw science mice on the rings
# Ring positions (elliptical, perspective)
ring_center_y = center_y
ring_radius_x = 180
ring_radius_y = 50
# Place 3-4 mice around the rings
num_mice = 4
for i in range(num_mice):
# Position along ring (ellipse)
t = (i / num_mice + mice_frame / mice_frames) * 2 * math.pi
mouse_x = center_x + ring_radius_x * math.cos(t)
mouse_y = ring_center_y + ring_radius_y * math.sin(t)
# Mouse angle (facing outward from center)
mouse_angle = t + math.pi / 2
# Alternate between taking notes and observing
note_taking = (i + mice_frame // 10) % 2 == 0
draw_mouse(draw, int(mouse_x), int(mouse_y), size=18,
angle=mouse_angle, note_taking=note_taking)
return frame
def main():
"""Generate the animated loading GIF"""
parser = argparse.ArgumentParser(
description="Generate animated loading GIF for Prole installer"
)
parser.add_argument(
"--size", type=str, default="1024x768",
help="Output size as WIDTHxHEIGHT (default: 1024x768)"
)
parser.add_argument(
"--frames", type=int, default=DEFAULT_FRAMES,
help=f"Total animation frames (default: {DEFAULT_FRAMES})"
)
parser.add_argument(
"--energy-frames", type=int, default=DEFAULT_ENERGY_FRAMES,
help=f"Energy animation cycle length (default: {DEFAULT_ENERGY_FRAMES})"
)
parser.add_argument(
"--mice-frames", type=int, default=DEFAULT_MICE_FRAMES,
help=f"Mice animation cycle length (default: {DEFAULT_MICE_FRAMES})"
)
parser.add_argument(
"--colors", type=int, default=DEFAULT_COLORS,
help=f"Color palette size for optimization (default: {DEFAULT_COLORS})"
)
parser.add_argument(
"--output", type=str, default=DEFAULT_OUTPUT_FILE,
help=f"Output filename (default: {DEFAULT_OUTPUT_FILE})"
)
parser.add_argument(
"--optimize", action="store_true", default=True,
help="Enable GIF optimization (default: True)"
)
args = parser.parse_args()
# Parse size
try:
width, height = map(int, args.size.split('x'))
OUTPUT_SIZE = (width, height)
except ValueError:
print(f"Error: Invalid size format '{args.size}'. Use WIDTHxHEIGHT (e.g., 1024x768)")
return
FRAMES_PER_CYCLE = args.frames
ENERGY_FRAMES = args.energy_frames | DEFAULT_ENERGY_FRAMES
MICE_FRAMES = args.mice_frames
NUM_COLORS = args.colors
OUTPUT_FILE = args.output
print("Loading base logo image...")
base_path = os.path.join(os.path.dirname(__file__), "proleLogoBlueprint.png")
if not os.path.exists(base_path):
print(f"Error: Base image not found at {base_path}")
return
# Load and resize base image
base_image = Image.open(base_path)
base_image = base_image.convert("RGB") # Ensure RGB mode
base_image = base_image.resize(OUTPUT_SIZE, Image.Resampling.LANCZOS)
print(f"Generating {FRAMES_PER_CYCLE} animation frames...")
frames = []
for frame_num in range(FRAMES_PER_CYCLE):
# Calculate sub-frame indices for different animation speeds
energy_frame = frame_num % ENERGY_FRAMES # Fast energy animation
mice_frame = frame_num % MICE_FRAMES # Slower mice animation
frame = generate_frame(base_image, frame_num, energy_frame, mice_frame, MICE_FRAMES)
frames.append(frame)
if (frame_num + 1) % 10 == 0:
print(f" Generated {frame_num + 1}/{FRAMES_PER_CYCLE} frames...")
print("Saving animated GIF...")
# Save as animated GIF
# Use different durations: faster for energy-heavy frames, slower for others
durations = []
for i in range(FRAMES_PER_CYCLE):
# Faster frames when energy is more intense
energy_intensity = abs(math.sin(i * math.pi * 2 / ENERGY_FRAMES))
duration = 50 + int(energy_intensity * 30) # 50-80ms per frame
durations.append(duration)
output_path = os.path.join(os.path.dirname(__file__), OUTPUT_FILE)
# Optimize: quantize to reduce colors and file size
if args.optimize:
print(f"Optimizing GIF (quantizing to {NUM_COLORS} colors)...")
quantized_frames = []
for frame in frames:
# Quantize to reduce colors for better compression
quantized = frame.quantize(colors=NUM_COLORS, method=Image.Quantize.MEDIANCUT)
quantized_frames.append(quantized.convert("P"))
frames_to_save = quantized_frames
else:
frames_to_save = frames
frames_to_save[0].save(
output_path,
save_all=True,
append_images=frames_to_save[1:],
duration=durations,
loop=0, # Infinite loop
optimize=args.optimize # Enable optimization
)
file_size = os.path.getsize(output_path) / (1024 * 1024) # Size in MB
print(f"✓ Animation saved to {output_path}")
print(f" Size: {OUTPUT_SIZE[0]}x{OUTPUT_SIZE[1]}")
print(f" Frames: {FRAMES_PER_CYCLE}")
print(f" Energy cycle: {ENERGY_FRAMES} frames (fast)")
print(f" Mice cycle: {MICE_FRAMES} frames (slow)")
print(f" File size: {file_size:.1f} MB")
if __name__ == "__main__":
main()