241 lines
8.4 KiB
Python
241 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
🎬 CHARACTER ANIMATION GENERATOR
|
||
Generates complete animation sets for Kai, Ana, Gronk
|
||
Based on CHARACTER_ANIMATION_MANIFEST.md
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
from PIL import Image
|
||
|
||
try:
|
||
import google.generativeai as genai
|
||
except ImportError:
|
||
print("❌ google-generativeai not installed!")
|
||
sys.exit(1)
|
||
|
||
# Configuration
|
||
REPO = Path("/Users/davidkotnik/repos/novafarma")
|
||
LIKI = REPO / "assets/slike/liki"
|
||
|
||
# API Setup
|
||
api_key = os.environ.get("GEMINI_API_KEY")
|
||
if not api_key:
|
||
print("❌ GEMINI_API_KEY not set!")
|
||
sys.exit(1)
|
||
|
||
genai.configure(api_key=api_key)
|
||
|
||
# Characters and their master references
|
||
CHARACTERS = {
|
||
"kai": {
|
||
"styleA_ref": "kai_master_styleA_reference.png",
|
||
"styleB_ref": "kai_master_styleB_reference.png",
|
||
"description": "young male adventurer with green hair and backpack"
|
||
},
|
||
"ana": {
|
||
"styleA_ref": "ana_master_styleA_reference.png",
|
||
"styleB_ref": "ana_master_styleB_reference.png",
|
||
"description": "young girl explorer with pink dreadlocks, green vest, cargo shorts"
|
||
},
|
||
"gronk": {
|
||
"styleA_ref": "gronk_master_styleA_reference.png",
|
||
"styleB_ref": "gronk_master_styleB_reference.png",
|
||
"description": "large friendly orc with green skin, pink dreadlocks, pink hoodie, vape pen"
|
||
}
|
||
}
|
||
|
||
# Animation definitions - PRIORITY 1: CORE MOVEMENT
|
||
ANIMATIONS_P1 = {
|
||
"walk": {
|
||
"frames": 4,
|
||
"directions": ["north", "south", "east", "west"],
|
||
"prompts": {
|
||
"north": "walking away from camera, back view, {frame} of 4 walk cycle",
|
||
"south": "walking toward camera, front view, {frame} of 4 walk cycle",
|
||
"east": "walking to the right, side view facing right, {frame} of 4 walk cycle",
|
||
"west": "walking to the left, side view facing left, {frame} of 4 walk cycle"
|
||
}
|
||
},
|
||
"idle": {
|
||
"frames": 1,
|
||
"directions": ["north", "south", "east", "west"],
|
||
"prompts": {
|
||
"north": "standing idle, back view",
|
||
"south": "standing idle, front view",
|
||
"east": "standing idle, side view facing right",
|
||
"west": "standing idle, side view facing left"
|
||
}
|
||
}
|
||
}
|
||
|
||
STYLE_PROMPTS = {
|
||
"stylea": "2D game character sprite, cartoon vector art style with bold black outlines, flat colors, cute playful aesthetic, isolated on pure white background",
|
||
"styleb": "2D game character sprite, dark hand-drawn gritty noir style with dramatic shadows, high contrast, sketchy atmospheric lines, isolated on pure white background"
|
||
}
|
||
|
||
def create_preview(image_path: Path, size=256):
|
||
"""Create preview version"""
|
||
try:
|
||
img = Image.open(image_path)
|
||
preview = img.resize((size, size), Image.Resampling.LANCZOS)
|
||
preview_path = image_path.parent / f"{image_path.stem}_preview_{size}x{size}.png"
|
||
preview.save(preview_path, 'PNG', optimize=True)
|
||
return preview_path
|
||
except Exception as e:
|
||
print(f" ⚠️ Preview failed: {e}")
|
||
return None
|
||
|
||
def generate_animation_frame(character, action, direction, frame, style, log_file):
|
||
"""Generate single animation frame"""
|
||
|
||
char_data = CHARACTERS[character]
|
||
style_suffix = style
|
||
|
||
# Build filename
|
||
if frame > 0:
|
||
filename = f"{character}_{action}_{direction}_{frame}_{style_suffix}.png"
|
||
else:
|
||
filename = f"{character}_{action}_{direction}_{style_suffix}.png"
|
||
|
||
output_path = LIKI / character / filename
|
||
|
||
# Skip if exists
|
||
if output_path.exists():
|
||
print(f" ⏭️ {filename} exists")
|
||
return True
|
||
|
||
# Build prompt
|
||
anim_data = ANIMATIONS_P1[action]
|
||
action_prompt = anim_data["prompts"][direction]
|
||
|
||
if "{frame}" in action_prompt:
|
||
action_prompt = action_prompt.format(frame=frame)
|
||
|
||
style_prompt = STYLE_PROMPTS[style_suffix]
|
||
|
||
full_prompt = f"""{style_prompt}
|
||
|
||
Character: {char_data['description']}
|
||
EXACTLY match the visual style and appearance from the master reference image.
|
||
|
||
Action: {action_prompt}
|
||
Full body visible, centered composition, game asset sprite.
|
||
Maintain complete visual consistency with reference - same colors, proportions, clothing details.
|
||
"""
|
||
|
||
start = time.time()
|
||
|
||
try:
|
||
print(f" 🎨 Generating: {filename}")
|
||
log_file.write(f"{time.strftime('%H:%M:%S')} - {filename}\n")
|
||
log_file.flush()
|
||
|
||
model = genai.GenerativeModel("gemini-2.5-flash")
|
||
response = model.generate_content([full_prompt])
|
||
|
||
if hasattr(response, '_result') and response._result.candidates:
|
||
image_data = response._result.candidates[0].content.parts[0].inline_data.data
|
||
|
||
# Save
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(output_path, 'wb') as f:
|
||
f.write(image_data)
|
||
|
||
# Preview
|
||
create_preview(output_path)
|
||
|
||
elapsed = time.time() - start
|
||
print(f" ✅ Saved ({elapsed:.1f}s)")
|
||
log_file.write(f"{time.strftime('%H:%M:%S')} - SUCCESS ({elapsed:.1f}s)\n")
|
||
log_file.flush()
|
||
|
||
return True
|
||
else:
|
||
print(f" ❌ No image data")
|
||
log_file.write(f"{time.strftime('%H:%M:%S')} - FAILED - No data\n")
|
||
log_file.flush()
|
||
return False
|
||
|
||
except Exception as e:
|
||
print(f" ❌ Error: {e}")
|
||
log_file.write(f"{time.strftime('%H:%M:%S')} - ERROR: {e}\n")
|
||
log_file.flush()
|
||
return False
|
||
|
||
def main():
|
||
# Create log
|
||
log_dir = REPO / "logs"
|
||
log_dir.mkdir(exist_ok=True)
|
||
log_file = open(log_dir / f"character_animations_{time.strftime('%Y%m%d_%H%M%S')}.log", 'w')
|
||
|
||
print("="*70)
|
||
print("🎬 CHARACTER ANIMATION GENERATOR - PRIORITY 1")
|
||
print("="*70)
|
||
print("\n3 Characters × 20 frames × 2 styles = 120 PNG\n")
|
||
|
||
log_file.write(f"CHARACTER ANIMATION GENERATION - {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||
log_file.write("="*70 + "\n\n")
|
||
log_file.flush()
|
||
|
||
stats = {'total': 0, 'success': 0, 'failed': 0}
|
||
|
||
try:
|
||
for character in CHARACTERS.keys():
|
||
print(f"\n{'='*70}")
|
||
print(f"👤 {character.upper()}")
|
||
print(f"{'='*70}")
|
||
|
||
for style in ["stylea", "styleb"]:
|
||
print(f"\n🎨 Style: {style}")
|
||
|
||
for action, anim_data in ANIMATIONS_P1.items():
|
||
print(f"\n 📹 {action.upper()}")
|
||
|
||
for direction in anim_data["directions"]:
|
||
frames = anim_data["frames"]
|
||
|
||
if frames == 1:
|
||
stats['total'] += 1
|
||
if generate_animation_frame(character, action, direction, 0, style, log_file):
|
||
stats['success'] += 1
|
||
else:
|
||
stats['failed'] += 1
|
||
time.sleep(15) # Rate limiting
|
||
else:
|
||
for frame in range(1, frames + 1):
|
||
stats['total'] += 1
|
||
if generate_animation_frame(character, action, direction, frame, style, log_file):
|
||
stats['success'] += 1
|
||
else:
|
||
stats['failed'] += 1
|
||
time.sleep(15) # Rate limiting
|
||
|
||
# Progress
|
||
progress = (stats['total'] / 120) * 100
|
||
print(f"\n📊 Overall Progress: {progress:.1f}% | ✅ {stats['success']} | ❌ {stats['failed']}")
|
||
|
||
# Final summary
|
||
print("\n" + "="*70)
|
||
print("🎉 GENERATION COMPLETE!")
|
||
print("="*70)
|
||
print(f"✅ Success: {stats['success']}/120")
|
||
print(f"❌ Failed: {stats['failed']}/120")
|
||
print(f"📊 Success rate: {(stats['success']/120)*100:.1f}%")
|
||
|
||
log_file.write(f"\n{'='*70}\n")
|
||
log_file.write(f"COMPLETE - Success: {stats['success']}, Failed: {stats['failed']}\n")
|
||
|
||
except KeyboardInterrupt:
|
||
print(f"\n\n⚠️ INTERRUPTED at {stats['total']}/120")
|
||
log_file.write(f"\n\nINTERRUPTED at {stats['total']}/120\n")
|
||
|
||
finally:
|
||
log_file.close()
|
||
|
||
if __name__ == "__main__":
|
||
main()
|