mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 10:13:58 +00:00
346 lines
12 KiB
Python
Executable File
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()
|
|
|