#!/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()