ismdrobiul489 commited on
Commit
4bb5c2c
·
1 Parent(s): e3dc2da

Major upgrade: Professional stick figure with multi-character, backgrounds, title screens, thought bubbles, visual metaphors, styled captions

Browse files
modules/art_reels/router.py CHANGED
@@ -139,9 +139,9 @@ async def generate_stick_figure_video(job_id: str, script: str, voice: str):
139
  update_job(job_id, "processing", 5)
140
  logger.info(f"Starting stick figure video with TTS for job {job_id}")
141
 
142
- # Import TTS, Whisper, and AI Stick Figure
143
  from ..story_reels.services.srt_parser import SRTParser
144
- from .services.ai_stick_figure import AIStickFigure
145
 
146
  # Get TTS client from app
147
  import sys
@@ -191,9 +191,9 @@ async def generate_stick_figure_video(job_id: str, script: str, voice: str):
191
 
192
  update_job(job_id, "processing", 45)
193
 
194
- # Step 4: Generate scenes with AI
195
- ai_stick = AIStickFigure()
196
- scenes = ai_stick.generate_scenes_with_ai(chunks)
197
 
198
  # Calculate durations for each chunk
199
  chunk_durations = []
@@ -208,7 +208,7 @@ async def generate_stick_figure_video(job_id: str, script: str, voice: str):
208
 
209
  # Step 5: Generate frames
210
  logger.info(f"Generating stick figure frames for job {job_id}")
211
- frame_paths = ai_stick.generate_frames_from_scenes(
212
  scenes=scenes,
213
  chunk_durations=chunk_durations,
214
  output_dir=temp_dir,
 
139
  update_job(job_id, "processing", 5)
140
  logger.info(f"Starting stick figure video with TTS for job {job_id}")
141
 
142
+ # Import TTS, Whisper, and Professional Stick Figure
143
  from ..story_reels.services.srt_parser import SRTParser
144
+ from .services.professional_stick_figure import ProfessionalStickFigure
145
 
146
  # Get TTS client from app
147
  import sys
 
191
 
192
  update_job(job_id, "processing", 45)
193
 
194
+ # Step 4: Generate scenes with AI (Professional)
195
+ pro_stick = ProfessionalStickFigure()
196
+ scenes = pro_stick.generate_scenes_with_ai(chunks)
197
 
198
  # Calculate durations for each chunk
199
  chunk_durations = []
 
208
 
209
  # Step 5: Generate frames
210
  logger.info(f"Generating stick figure frames for job {job_id}")
211
+ frame_paths = pro_stick.generate_frames_from_scenes(
212
  scenes=scenes,
213
  chunk_durations=chunk_durations,
214
  output_dir=temp_dir,
modules/art_reels/services/professional_stick_figure.py ADDED
@@ -0,0 +1,1024 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Professional Stick Figure Animation System
3
+ Whiteboard-style animation with multiple characters, backgrounds, and visual metaphors
4
+ """
5
+ import logging
6
+ import os
7
+ import json
8
+ import math
9
+ import random
10
+ from PIL import Image, ImageDraw, ImageFont
11
+ from typing import List, Tuple, Dict, Optional
12
+ from groq import Groq
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ # Enhanced AI System Prompt for professional scene generation
18
+ PROFESSIONAL_SCENE_PROMPT = """You are an AI that creates professional whiteboard animation scenes.
19
+
20
+ For each 2-second narration chunk, generate a detailed scene description.
21
+
22
+ SCENE TYPES:
23
+ - "title": Text on white background (for introductions, key terms)
24
+ - "single": One character doing something
25
+ - "dual": Two characters interacting
26
+ - "multi": 3+ characters in a scene
27
+ - "metaphor": Visual metaphor (bridge, rope, handshake)
28
+ - "chart": Show a simple graph or comparison
29
+
30
+ BACKGROUNDS:
31
+ - "white": Default clean background
32
+ - "blackboard": Teacher/education scene
33
+ - "office": Work/business scene
34
+ - "outdoor": Nature, street, park
35
+ - "room": Indoor with furniture
36
+
37
+ POSES: standing, walking, running, sitting, sleeping, waving, thinking, jumping, celebrating, pointing, talking
38
+
39
+ EMOTIONS: happy, sad, thinking, excited, tired, angry, surprised, confused
40
+
41
+ INTERACTIONS (for dual/multi scenes):
42
+ - "handshake": Agreement/cooperation
43
+ - "tug_of_war": Conflict/competition
44
+ - "conversation": Talking to each other
45
+ - "helping": One helping another
46
+ - "fighting": Conflict
47
+
48
+ OUTPUT FORMAT (JSON array):
49
+ [
50
+ {
51
+ "chunk_id": 0,
52
+ "scene_type": "title|single|dual|multi|metaphor|chart",
53
+ "background": "white|blackboard|office|outdoor|room",
54
+ "title_text": "Main Title Here" or null,
55
+ "subtitle_text": "Subtitle here" or null,
56
+ "characters": [
57
+ {
58
+ "position": "left|center|right",
59
+ "pose": "standing|walking|etc",
60
+ "emotion": "happy|sad|etc",
61
+ "props": ["crown", "book", etc],
62
+ "thought_bubble": "What they are thinking" or null,
63
+ "label": "Teacher" or null
64
+ }
65
+ ],
66
+ "metaphor": "bridge|rope|scale|chart" or null,
67
+ "metaphor_state": "intact|breaking|balanced" or null,
68
+ "caption": "Short caption text for this scene"
69
+ }
70
+ ]
71
+
72
+ RULES:
73
+ 1. Match scenes to narration MEANING - use visual metaphors
74
+ 2. Use title scenes for new concepts (like "Prisoner's Dilemma")
75
+ 3. Use dual scenes for comparisons and interactions
76
+ 4. Add thought bubbles for internal decisions
77
+ 5. Always include a short caption summarizing the scene
78
+ 6. Be creative with visual storytelling
79
+
80
+ EXAMPLES:
81
+ - "Game theory explained" -> title scene with "GAME THEORY" as title
82
+ - "Two prisoners are questioned" -> dual scene with jail background, both sitting
83
+ - "They must decide to cooperate or betray" -> dual with thought bubbles "Silent?" vs "Betray?"
84
+ - "One cuts the bridge" -> metaphor scene with bridge breaking
85
+ """
86
+
87
+
88
+ class ProfessionalStickFigure:
89
+ """
90
+ Professional Whiteboard Animation System.
91
+
92
+ Features:
93
+ - Multiple characters with positions
94
+ - Scene backgrounds
95
+ - Title screens
96
+ - Thought bubbles
97
+ - Visual metaphors
98
+ - Captions with proper styling
99
+ """
100
+
101
+ # Video dimensions (9:16 portrait)
102
+ WIDTH = 1080
103
+ HEIGHT = 1920
104
+
105
+ # Stick figure dimensions
106
+ HEAD_RADIUS = 40
107
+ BODY_LENGTH = 120
108
+ ARM_LENGTH = 80
109
+ LEG_LENGTH = 100
110
+ LINE_WIDTH = 8
111
+
112
+ # Professional color scheme (black & white with accents)
113
+ BG_COLOR = (255, 255, 255) # White
114
+ FIGURE_COLOR = (30, 30, 30) # Near black
115
+ ACCENT_COLOR = (41, 128, 185) # Professional blue
116
+ TITLE_COLOR = (44, 62, 80) # Dark blue-gray
117
+ CAPTION_BG = (0, 0, 0) # Black
118
+ CAPTION_TEXT = (255, 255, 255) # White
119
+ THOUGHT_BG = (245, 245, 245) # Light gray
120
+ HIGHLIGHT = (231, 76, 60) # Red for emphasis
121
+
122
+ # Character positions (x coordinates)
123
+ POSITIONS = {
124
+ "left": 270,
125
+ "center": 540,
126
+ "right": 810
127
+ }
128
+
129
+ # Character Y base
130
+ CHARACTER_Y = 1100
131
+
132
+ def __init__(self, groq_api_key: str = None):
133
+ self.groq_api_key = groq_api_key or os.environ.get("GROQ_API_KEY")
134
+ if self.groq_api_key:
135
+ self.groq = Groq(api_key=self.groq_api_key)
136
+ else:
137
+ self.groq = None
138
+ logger.warning("Groq API key not found - AI scene generation disabled")
139
+
140
+ # Load fonts
141
+ self._load_fonts()
142
+
143
+ def _load_fonts(self):
144
+ """Load fonts with fallbacks"""
145
+ try:
146
+ self.font_title = ImageFont.truetype("arial.ttf", 72)
147
+ self.font_subtitle = ImageFont.truetype("arial.ttf", 48)
148
+ self.font_caption = ImageFont.truetype("arial.ttf", 42)
149
+ self.font_thought = ImageFont.truetype("arial.ttf", 32)
150
+ self.font_label = ImageFont.truetype("arial.ttf", 28)
151
+ except:
152
+ self.font_title = ImageFont.load_default()
153
+ self.font_subtitle = ImageFont.load_default()
154
+ self.font_caption = ImageFont.load_default()
155
+ self.font_thought = ImageFont.load_default()
156
+ self.font_label = ImageFont.load_default()
157
+
158
+ # ==========================================
159
+ # AI SCENE GENERATION
160
+ # ==========================================
161
+
162
+ def generate_scenes_with_ai(self, chunks: List[Dict]) -> List[Dict]:
163
+ """Generate professional scene descriptions using AI"""
164
+ if not self.groq:
165
+ return self._generate_fallback_scenes(chunks)
166
+
167
+ try:
168
+ chunk_texts = "\n".join([
169
+ f"Chunk {c.get('chunk_id', i)}: \"{c.get('text', '')}\""
170
+ for i, c in enumerate(chunks)
171
+ ])
172
+
173
+ user_prompt = f"""Create professional whiteboard animation scenes for:
174
+
175
+ {chunk_texts}
176
+
177
+ Generate exactly {len(chunks)} scenes. Return ONLY valid JSON array."""
178
+
179
+ response = self.groq.chat.completions.create(
180
+ model="openai/gpt-oss-120b",
181
+ messages=[
182
+ {"role": "system", "content": PROFESSIONAL_SCENE_PROMPT},
183
+ {"role": "user", "content": user_prompt}
184
+ ],
185
+ temperature=0.7,
186
+ max_tokens=4000
187
+ )
188
+
189
+ content = response.choices[0].message.content.strip()
190
+
191
+ # Parse JSON
192
+ if "```" in content:
193
+ content = content.split("```")[1]
194
+ if content.startswith("json"):
195
+ content = content[4:]
196
+
197
+ scenes = json.loads(content)
198
+ logger.info(f"AI generated {len(scenes)} professional scenes")
199
+ return scenes
200
+
201
+ except Exception as e:
202
+ logger.error(f"AI scene generation failed: {e}")
203
+ return self._generate_fallback_scenes(chunks)
204
+
205
+ def _generate_fallback_scenes(self, chunks: List[Dict]) -> List[Dict]:
206
+ """Fallback scene generation"""
207
+ scenes = []
208
+ for i, chunk in enumerate(chunks):
209
+ text = chunk.get("text", "").lower()
210
+
211
+ # Detect scene type
212
+ scene_type = "single"
213
+ if any(word in text for word in ["vs", "versus", "compare", "between", "both", "two"]):
214
+ scene_type = "dual"
215
+ elif any(word in text for word in ["introduce", "what is", "explained", "definition"]):
216
+ scene_type = "title"
217
+
218
+ scenes.append({
219
+ "chunk_id": i,
220
+ "scene_type": scene_type,
221
+ "background": "white",
222
+ "title_text": text[:30].upper() if scene_type == "title" else None,
223
+ "characters": [
224
+ {"position": "center", "pose": "standing", "emotion": "happy", "props": []}
225
+ ] if scene_type != "title" else [],
226
+ "caption": text[:50] + "..." if len(text) > 50 else text
227
+ })
228
+
229
+ return scenes
230
+
231
+ # ==========================================
232
+ # BACKGROUND DRAWING
233
+ # ==========================================
234
+
235
+ def draw_background(self, draw: ImageDraw, bg_type: str = "white"):
236
+ """Draw scene background"""
237
+ if bg_type == "blackboard":
238
+ # Green chalkboard
239
+ draw.rectangle([0, 0, self.WIDTH, self.HEIGHT], fill=(34, 49, 39))
240
+ # Frame
241
+ draw.rectangle([30, 200, self.WIDTH - 30, 900],
242
+ outline=(139, 90, 43), width=15)
243
+ draw.rectangle([40, 210, self.WIDTH - 40, 890],
244
+ fill=(40, 68, 50))
245
+
246
+ elif bg_type == "office":
247
+ # Light gray wall
248
+ draw.rectangle([0, 0, self.WIDTH, self.HEIGHT], fill=(240, 240, 240))
249
+ # Floor
250
+ draw.rectangle([0, 1400, self.WIDTH, self.HEIGHT], fill=(180, 150, 120))
251
+ # Simple desk
252
+ draw.rectangle([200, 1200, 880, 1250], fill=(139, 90, 43))
253
+ draw.rectangle([250, 1250, 300, 1400], fill=(100, 65, 30))
254
+ draw.rectangle([780, 1250, 830, 1400], fill=(100, 65, 30))
255
+
256
+ elif bg_type == "outdoor":
257
+ # Sky gradient (simplified)
258
+ draw.rectangle([0, 0, self.WIDTH, 600], fill=(135, 206, 235))
259
+ # Ground
260
+ draw.rectangle([0, 1400, self.WIDTH, self.HEIGHT], fill=(76, 153, 0))
261
+ # Sun
262
+ draw.ellipse([850, 100, 1000, 250], fill=(255, 223, 0))
263
+
264
+ elif bg_type == "room":
265
+ # Wall
266
+ draw.rectangle([0, 0, self.WIDTH, self.HEIGHT], fill=(255, 248, 240))
267
+ # Floor
268
+ draw.rectangle([0, 1400, self.WIDTH, self.HEIGHT], fill=(139, 119, 101))
269
+
270
+ # Default white - no additional drawing needed
271
+
272
+ # ==========================================
273
+ # TITLE SCREEN
274
+ # ==========================================
275
+
276
+ def draw_title_screen(
277
+ self,
278
+ img: Image.Image,
279
+ title: str,
280
+ subtitle: str = None
281
+ ):
282
+ """Draw professional title screen"""
283
+ draw = ImageDraw.Draw(img)
284
+
285
+ # Main title
286
+ bbox = draw.textbbox((0, 0), title, font=self.font_title)
287
+ title_width = bbox[2] - bbox[0]
288
+ title_x = (self.WIDTH - title_width) // 2
289
+ title_y = self.HEIGHT // 2 - 100
290
+
291
+ # Draw title with underline
292
+ draw.text((title_x, title_y), title, fill=self.TITLE_COLOR, font=self.font_title)
293
+
294
+ # Underline
295
+ draw.line([title_x, title_y + 90, title_x + title_width, title_y + 90],
296
+ fill=self.ACCENT_COLOR, width=4)
297
+
298
+ # Subtitle if provided
299
+ if subtitle:
300
+ bbox = draw.textbbox((0, 0), subtitle, font=self.font_subtitle)
301
+ sub_width = bbox[2] - bbox[0]
302
+ sub_x = (self.WIDTH - sub_width) // 2
303
+ draw.text((sub_x, title_y + 120), subtitle,
304
+ fill=(100, 100, 100), font=self.font_subtitle)
305
+
306
+ # ==========================================
307
+ # CAPTION (Bottom Bar)
308
+ # ==========================================
309
+
310
+ def draw_caption(
311
+ self,
312
+ img: Image.Image,
313
+ caption: str,
314
+ position: str = "bottom"
315
+ ):
316
+ """Draw professional caption bar"""
317
+ if not caption:
318
+ return
319
+
320
+ draw = ImageDraw.Draw(img)
321
+
322
+ # Caption background
323
+ caption_height = 120
324
+ if position == "bottom":
325
+ caption_y = self.HEIGHT - caption_height - 50
326
+ else:
327
+ caption_y = 50
328
+
329
+ # Semi-transparent background
330
+ draw.rectangle([40, caption_y, self.WIDTH - 40, caption_y + caption_height],
331
+ fill=(0, 0, 0), outline=None)
332
+
333
+ # Text
334
+ bbox = draw.textbbox((0, 0), caption, font=self.font_caption)
335
+ text_width = bbox[2] - bbox[0]
336
+ text_x = (self.WIDTH - text_width) // 2
337
+ text_y = caption_y + (caption_height - 42) // 2
338
+
339
+ draw.text((text_x, text_y), caption, fill=self.CAPTION_TEXT, font=self.font_caption)
340
+
341
+ # ==========================================
342
+ # THOUGHT BUBBLE
343
+ # ==========================================
344
+
345
+ def draw_thought_bubble(
346
+ self,
347
+ draw: ImageDraw,
348
+ x: int,
349
+ y: int,
350
+ text: str,
351
+ direction: str = "right"
352
+ ):
353
+ """Draw thought bubble with text"""
354
+ # Bubble dimensions
355
+ padding = 20
356
+ bbox = draw.textbbox((0, 0), text, font=self.font_thought)
357
+ text_width = bbox[2] - bbox[0]
358
+ text_height = bbox[3] - bbox[1]
359
+
360
+ bubble_width = text_width + padding * 2
361
+ bubble_height = text_height + padding * 2
362
+
363
+ # Position bubble
364
+ if direction == "right":
365
+ bubble_x = x + 30
366
+ else:
367
+ bubble_x = x - bubble_width - 30
368
+ bubble_y = y - 150
369
+
370
+ # Draw bubble (ellipse)
371
+ draw.ellipse([bubble_x, bubble_y, bubble_x + bubble_width, bubble_y + bubble_height],
372
+ fill=self.THOUGHT_BG, outline=self.FIGURE_COLOR, width=2)
373
+
374
+ # Small circles leading to head
375
+ for i, offset in enumerate([(15, 40), (8, 60), (3, 75)]):
376
+ size = 12 - i * 3
377
+ cx = x + (offset[0] if direction == "right" else -offset[0])
378
+ cy = y - 80 + offset[1]
379
+ draw.ellipse([cx - size, cy - size, cx + size, cy + size],
380
+ fill=self.THOUGHT_BG, outline=self.FIGURE_COLOR, width=2)
381
+
382
+ # Text
383
+ draw.text((bubble_x + padding, bubble_y + padding), text,
384
+ fill=self.FIGURE_COLOR, font=self.font_thought)
385
+
386
+ # ==========================================
387
+ # STICK FIGURE DRAWING
388
+ # ==========================================
389
+
390
+ def draw_stick_figure(
391
+ self,
392
+ draw: ImageDraw,
393
+ x: int,
394
+ y: int,
395
+ pose: str = "standing",
396
+ emotion: str = "happy",
397
+ props: List[str] = None,
398
+ scale: float = 1.0,
399
+ facing: str = "front" # front, left, right
400
+ ):
401
+ """Draw expressive stick figure"""
402
+ props = props or []
403
+
404
+ # Scale dimensions
405
+ head_r = int(self.HEAD_RADIUS * scale)
406
+ body_len = int(self.BODY_LENGTH * scale)
407
+ arm_len = int(self.ARM_LENGTH * scale)
408
+ leg_len = int(self.LEG_LENGTH * scale)
409
+ line_w = max(4, int(self.LINE_WIDTH * scale))
410
+
411
+ # Draw based on pose
412
+ pose_methods = {
413
+ "standing": self._draw_standing,
414
+ "walking": self._draw_walking,
415
+ "running": self._draw_running,
416
+ "sitting": self._draw_sitting,
417
+ "sleeping": self._draw_sleeping,
418
+ "waving": self._draw_waving,
419
+ "thinking": self._draw_thinking,
420
+ "jumping": self._draw_jumping,
421
+ "celebrating": self._draw_celebrating,
422
+ "pointing": self._draw_pointing,
423
+ "talking": self._draw_talking,
424
+ }
425
+
426
+ draw_method = pose_methods.get(pose, self._draw_standing)
427
+ draw_method(draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion)
428
+
429
+ # Draw props
430
+ for prop in props:
431
+ self._draw_prop(draw, x, y - body_len - head_r, head_r, prop, scale)
432
+
433
+ def _draw_face(self, draw, x, y, head_r, emotion, line_w):
434
+ """Draw expressive face"""
435
+ eye_y = y - head_r // 4
436
+ eye_offset = head_r // 3
437
+ eye_size = head_r // 5
438
+
439
+ if emotion == "happy":
440
+ # Happy curved eyes
441
+ draw.arc([x - eye_offset - eye_size, eye_y - eye_size,
442
+ x - eye_offset + eye_size, eye_y + eye_size],
443
+ 0, 180, fill=self.FIGURE_COLOR, width=line_w//2)
444
+ draw.arc([x + eye_offset - eye_size, eye_y - eye_size,
445
+ x + eye_offset + eye_size, eye_y + eye_size],
446
+ 0, 180, fill=self.FIGURE_COLOR, width=line_w//2)
447
+ # Smile
448
+ draw.arc([x - head_r//2, y - head_r//4, x + head_r//2, y + head_r//2],
449
+ 0, 180, fill=self.FIGURE_COLOR, width=line_w//2)
450
+
451
+ elif emotion == "sad":
452
+ draw.ellipse([x - eye_offset - eye_size, eye_y - eye_size,
453
+ x - eye_offset + eye_size, eye_y + eye_size],
454
+ fill=self.FIGURE_COLOR)
455
+ draw.ellipse([x + eye_offset - eye_size, eye_y - eye_size,
456
+ x + eye_offset + eye_size, eye_y + eye_size],
457
+ fill=self.FIGURE_COLOR)
458
+ # Frown
459
+ draw.arc([x - head_r//2, y, x + head_r//2, y + head_r//2],
460
+ 180, 360, fill=self.FIGURE_COLOR, width=line_w//2)
461
+
462
+ elif emotion == "thinking":
463
+ draw.ellipse([x - eye_offset - eye_size, eye_y - eye_size - 5,
464
+ x - eye_offset + eye_size, eye_y + eye_size - 5],
465
+ fill=self.FIGURE_COLOR)
466
+ draw.ellipse([x + eye_offset - eye_size, eye_y - eye_size - 5,
467
+ x + eye_offset + eye_size, eye_y + eye_size - 5],
468
+ fill=self.FIGURE_COLOR)
469
+ # Neutral
470
+ draw.line([x - head_r//3, y + head_r//4, x + head_r//3, y + head_r//4],
471
+ fill=self.FIGURE_COLOR, width=line_w//2)
472
+
473
+ elif emotion == "excited":
474
+ big_eye = eye_size * 2
475
+ draw.ellipse([x - eye_offset - big_eye, eye_y - big_eye,
476
+ x - eye_offset + big_eye, eye_y + big_eye],
477
+ fill=self.FIGURE_COLOR)
478
+ draw.ellipse([x + eye_offset - big_eye, eye_y - big_eye,
479
+ x + eye_offset + big_eye, eye_y + big_eye],
480
+ fill=self.FIGURE_COLOR)
481
+ draw.arc([x - head_r//2, y - head_r//3, x + head_r//2, y + head_r//2],
482
+ 0, 180, fill=self.FIGURE_COLOR, width=line_w)
483
+
484
+ elif emotion == "surprised":
485
+ # Wide eyes
486
+ draw.ellipse([x - eye_offset - eye_size*2, eye_y - eye_size*2,
487
+ x - eye_offset + eye_size*2, eye_y + eye_size*2],
488
+ outline=self.FIGURE_COLOR, width=line_w//2)
489
+ draw.ellipse([x + eye_offset - eye_size*2, eye_y - eye_size*2,
490
+ x + eye_offset + eye_size*2, eye_y + eye_size*2],
491
+ outline=self.FIGURE_COLOR, width=line_w//2)
492
+ # O mouth
493
+ draw.ellipse([x - head_r//4, y + head_r//8, x + head_r//4, y + head_r//2],
494
+ outline=self.FIGURE_COLOR, width=line_w//2)
495
+
496
+ else:
497
+ # Default neutral
498
+ draw.ellipse([x - eye_offset - eye_size, eye_y - eye_size,
499
+ x - eye_offset + eye_size, eye_y + eye_size],
500
+ fill=self.FIGURE_COLOR)
501
+ draw.ellipse([x + eye_offset - eye_size, eye_y - eye_size,
502
+ x + eye_offset + eye_size, eye_y + eye_size],
503
+ fill=self.FIGURE_COLOR)
504
+ draw.line([x - head_r//3, y + head_r//4, x + head_r//3, y + head_r//4],
505
+ fill=self.FIGURE_COLOR, width=line_w//2)
506
+
507
+ def _draw_standing(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
508
+ """Draw standing pose"""
509
+ head_y = y - body_len - head_r
510
+
511
+ draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
512
+ outline=self.FIGURE_COLOR, width=line_w)
513
+ self._draw_face(draw, x, head_y, head_r, emotion, line_w)
514
+
515
+ body_top = head_y + head_r
516
+ body_bottom = body_top + body_len
517
+ draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
518
+
519
+ arm_y = body_top + body_len // 4
520
+ draw.line([x, arm_y, x - arm_len, arm_y + arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
521
+ draw.line([x, arm_y, x + arm_len, arm_y + arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
522
+
523
+ draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
524
+ draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
525
+
526
+ def _draw_walking(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
527
+ """Draw walking pose"""
528
+ head_y = y - body_len - head_r
529
+
530
+ draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
531
+ outline=self.FIGURE_COLOR, width=line_w)
532
+ self._draw_face(draw, x, head_y, head_r, emotion, line_w)
533
+
534
+ body_top = head_y + head_r
535
+ body_bottom = body_top + body_len
536
+ draw.line([x, body_top, x + 10, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
537
+
538
+ arm_y = body_top + body_len // 4
539
+ draw.line([x, arm_y, x - arm_len//2, arm_y + arm_len], fill=self.FIGURE_COLOR, width=line_w)
540
+ draw.line([x, arm_y, x + arm_len, arm_y - arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
541
+
542
+ draw.line([x + 10, body_bottom, x - leg_len, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
543
+ draw.line([x + 10, body_bottom, x + leg_len, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
544
+
545
+ def _draw_running(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
546
+ """Draw running pose"""
547
+ head_y = y - body_len - head_r
548
+
549
+ draw.ellipse([x + 20 - head_r, head_y - head_r, x + 20 + head_r, head_y + head_r],
550
+ outline=self.FIGURE_COLOR, width=line_w)
551
+ self._draw_face(draw, x + 20, head_y, head_r, "excited", line_w)
552
+
553
+ body_top = head_y + head_r
554
+ body_bottom = body_top + body_len
555
+ draw.line([x + 20, body_top, x + 40, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
556
+
557
+ arm_y = body_top + body_len // 4 + 20
558
+ draw.line([x + 20, arm_y, x - arm_len, arm_y + arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
559
+ draw.line([x + 20, arm_y, x + arm_len + 30, arm_y - arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
560
+
561
+ draw.line([x + 40, body_bottom, x - leg_len - 20, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
562
+ draw.line([x + 40, body_bottom, x + leg_len + 40, body_bottom + leg_len//3], fill=self.FIGURE_COLOR, width=line_w)
563
+
564
+ def _draw_sitting(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
565
+ """Draw sitting pose"""
566
+ y_offset = leg_len // 2
567
+ head_y = y - body_len - head_r + y_offset
568
+
569
+ draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
570
+ outline=self.FIGURE_COLOR, width=line_w)
571
+ self._draw_face(draw, x, head_y, head_r, emotion, line_w)
572
+
573
+ body_top = head_y + head_r
574
+ body_bottom = body_top + body_len // 2
575
+ draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
576
+
577
+ arm_y = body_top + body_len // 6
578
+ draw.line([x, arm_y, x - arm_len // 2, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
579
+ draw.line([x, arm_y, x + arm_len // 2, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
580
+
581
+ # Chair
582
+ draw.line([x - leg_len, body_bottom, x + leg_len, body_bottom], fill=self.FIGURE_COLOR, width=line_w//2)
583
+ draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
584
+ draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
585
+
586
+ def _draw_sleeping(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
587
+ """Draw sleeping pose (horizontal)"""
588
+ draw.ellipse([x - body_len - head_r, y - head_r, x - body_len + head_r, y + head_r],
589
+ outline=self.FIGURE_COLOR, width=line_w)
590
+
591
+ # Closed eyes
592
+ draw.line([x - body_len - head_r//2, y - head_r//4, x - body_len - head_r//4, y - head_r//4],
593
+ fill=self.FIGURE_COLOR, width=line_w//2)
594
+ draw.line([x - body_len + head_r//4, y - head_r//4, x - body_len + head_r//2, y - head_r//4],
595
+ fill=self.FIGURE_COLOR, width=line_w//2)
596
+
597
+ draw.line([x - body_len + head_r, y, x, y], fill=self.FIGURE_COLOR, width=line_w)
598
+ draw.line([x, y, x + leg_len, y + 10], fill=self.FIGURE_COLOR, width=line_w)
599
+ draw.line([x, y, x + leg_len, y - 10], fill=self.FIGURE_COLOR, width=line_w)
600
+
601
+ # Zzz
602
+ draw.text((x - body_len, y - head_r - 50), "Zzz", fill=(150, 150, 150), font=self.font_label)
603
+
604
+ # Bed
605
+ draw.rectangle([x - body_len - head_r - 20, y + head_r, x + leg_len + 20, y + head_r + 20],
606
+ fill=(139, 90, 43))
607
+
608
+ def _draw_waving(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
609
+ """Draw waving pose"""
610
+ head_y = y - body_len - head_r
611
+
612
+ draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
613
+ outline=self.FIGURE_COLOR, width=line_w)
614
+ self._draw_face(draw, x, head_y, head_r, "happy", line_w)
615
+
616
+ body_top = head_y + head_r
617
+ body_bottom = body_top + body_len
618
+ draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
619
+
620
+ arm_y = body_top + body_len // 4
621
+ draw.line([x, arm_y, x - arm_len, arm_y + arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
622
+ draw.line([x, arm_y, x + arm_len//2, arm_y - arm_len], fill=self.FIGURE_COLOR, width=line_w)
623
+
624
+ # Wave lines
625
+ draw.line([x + arm_len//2 - 10, arm_y - arm_len - 30, x + arm_len//2 + 10, arm_y - arm_len - 20],
626
+ fill=self.ACCENT_COLOR, width=3)
627
+
628
+ draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
629
+ draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
630
+
631
+ def _draw_thinking(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
632
+ """Draw thinking pose"""
633
+ head_y = y - body_len - head_r
634
+
635
+ draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
636
+ outline=self.FIGURE_COLOR, width=line_w)
637
+ self._draw_face(draw, x, head_y, head_r, "thinking", line_w)
638
+
639
+ body_top = head_y + head_r
640
+ body_bottom = body_top + body_len
641
+ draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
642
+
643
+ arm_y = body_top + body_len // 4
644
+ draw.line([x, arm_y, x - arm_len, arm_y + arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
645
+ # Hand on chin
646
+ draw.line([x, arm_y, x + arm_len//3, arm_y - arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
647
+ draw.line([x + arm_len//3, arm_y - arm_len//3, x + head_r//2, head_y + head_r],
648
+ fill=self.FIGURE_COLOR, width=line_w)
649
+
650
+ draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
651
+ draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
652
+
653
+ def _draw_jumping(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
654
+ """Draw jumping pose"""
655
+ y_offset = -80
656
+ head_y = y - body_len - head_r + y_offset
657
+
658
+ draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
659
+ outline=self.FIGURE_COLOR, width=line_w)
660
+ self._draw_face(draw, x, head_y, head_r, "excited", line_w)
661
+
662
+ body_top = head_y + head_r
663
+ body_bottom = body_top + body_len
664
+ draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
665
+
666
+ arm_y = body_top + body_len // 4
667
+ draw.line([x, arm_y, x - arm_len, arm_y - arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
668
+ draw.line([x, arm_y, x + arm_len, arm_y - arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
669
+
670
+ draw.line([x, body_bottom, x - leg_len, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
671
+ draw.line([x, body_bottom, x + leg_len, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
672
+
673
+ # Jump line
674
+ draw.line([x - 30, body_bottom + leg_len + 30, x + 30, body_bottom + leg_len + 30],
675
+ fill=(200, 200, 200), width=4)
676
+
677
+ def _draw_celebrating(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
678
+ """Draw celebrating pose"""
679
+ head_y = y - body_len - head_r
680
+
681
+ draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
682
+ outline=self.FIGURE_COLOR, width=line_w)
683
+ self._draw_face(draw, x, head_y, head_r, "excited", line_w)
684
+
685
+ body_top = head_y + head_r
686
+ body_bottom = body_top + body_len
687
+ draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
688
+
689
+ arm_y = body_top + body_len // 4
690
+ draw.line([x, arm_y, x - arm_len, arm_y - arm_len], fill=self.FIGURE_COLOR, width=line_w)
691
+ draw.line([x, arm_y, x + arm_len, arm_y - arm_len], fill=self.FIGURE_COLOR, width=line_w)
692
+
693
+ draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
694
+ draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
695
+
696
+ # Confetti
697
+ colors = [(255, 87, 51), (255, 195, 0), (76, 175, 80), (33, 150, 243)]
698
+ for _ in range(8):
699
+ cx = x + random.randint(-120, 120)
700
+ cy = head_y + random.randint(-120, 40)
701
+ color = random.choice(colors)
702
+ draw.ellipse([cx - 4, cy - 4, cx + 4, cy + 4], fill=color)
703
+
704
+ def _draw_pointing(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
705
+ """Draw pointing pose"""
706
+ head_y = y - body_len - head_r
707
+
708
+ draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
709
+ outline=self.FIGURE_COLOR, width=line_w)
710
+ self._draw_face(draw, x, head_y, head_r, emotion, line_w)
711
+
712
+ body_top = head_y + head_r
713
+ body_bottom = body_top + body_len
714
+ draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
715
+
716
+ arm_y = body_top + body_len // 4
717
+ draw.line([x, arm_y, x - arm_len, arm_y + arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
718
+ # Pointing arm
719
+ draw.line([x, arm_y, x + arm_len + 20, arm_y - arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
720
+
721
+ draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
722
+ draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
723
+
724
+ def _draw_talking(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
725
+ """Draw talking pose with speech lines"""
726
+ head_y = y - body_len - head_r
727
+
728
+ draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
729
+ outline=self.FIGURE_COLOR, width=line_w)
730
+
731
+ # Open mouth
732
+ eye_y = head_y - head_r // 4
733
+ eye_offset = head_r // 3
734
+ eye_size = head_r // 5
735
+ draw.ellipse([x - eye_offset - eye_size, eye_y - eye_size,
736
+ x - eye_offset + eye_size, eye_y + eye_size],
737
+ fill=self.FIGURE_COLOR)
738
+ draw.ellipse([x + eye_offset - eye_size, eye_y - eye_size,
739
+ x + eye_offset + eye_size, eye_y + eye_size],
740
+ fill=self.FIGURE_COLOR)
741
+ # Talking mouth
742
+ draw.ellipse([x - head_r//3, head_y + head_r//6, x + head_r//3, head_y + head_r//2],
743
+ outline=self.FIGURE_COLOR, width=line_w//2)
744
+
745
+ # Speech lines
746
+ for i in range(3):
747
+ sx = x + head_r + 10 + i * 15
748
+ sy = head_y - 10 + i * 5
749
+ draw.line([sx, sy, sx + 20, sy], fill=(150, 150, 150), width=3)
750
+
751
+ body_top = head_y + head_r
752
+ body_bottom = body_top + body_len
753
+ draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
754
+
755
+ arm_y = body_top + body_len // 4
756
+ draw.line([x, arm_y, x - arm_len, arm_y + arm_len//4], fill=self.FIGURE_COLOR, width=line_w)
757
+ draw.line([x, arm_y, x + arm_len, arm_y + arm_len//4], fill=self.FIGURE_COLOR, width=line_w)
758
+
759
+ draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
760
+ draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
761
+
762
+ # ==========================================
763
+ # PROPS DRAWING
764
+ # ==========================================
765
+
766
+ def _draw_prop(self, draw, x, head_y, head_r, prop: str, scale: float):
767
+ """Draw props"""
768
+ if prop == "crown":
769
+ crown_y = head_y - head_r - 15
770
+ points = [
771
+ (x - 35, crown_y),
772
+ (x - 20, crown_y - 35),
773
+ (x, crown_y - 15),
774
+ (x + 20, crown_y - 35),
775
+ (x + 35, crown_y)
776
+ ]
777
+ draw.polygon(points, fill=(255, 215, 0), outline=(200, 170, 0), width=2)
778
+
779
+ elif prop == "glasses":
780
+ glass_y = head_y - head_r // 4
781
+ # Left lens
782
+ draw.ellipse([x - head_r//2 - 15, glass_y - 12, x - head_r//2 + 15, glass_y + 12],
783
+ outline=self.FIGURE_COLOR, width=3)
784
+ # Right lens
785
+ draw.ellipse([x + head_r//2 - 15, glass_y - 12, x + head_r//2 + 15, glass_y + 12],
786
+ outline=self.FIGURE_COLOR, width=3)
787
+ # Bridge
788
+ draw.line([x - head_r//2 + 15, glass_y, x + head_r//2 - 15, glass_y],
789
+ fill=self.FIGURE_COLOR, width=2)
790
+
791
+ elif prop == "money":
792
+ bag_x = x + 80
793
+ bag_y = head_y + 120
794
+ draw.ellipse([bag_x, bag_y, bag_x + 50, bag_y + 60],
795
+ fill=(34, 139, 34), outline=(20, 100, 20), width=2)
796
+ draw.text((bag_x + 18, bag_y + 15), "$", fill=(255, 255, 255), font=self.font_label)
797
+
798
+ elif prop == "book":
799
+ book_x = x + 70
800
+ book_y = head_y + 100
801
+ draw.rectangle([book_x, book_y, book_x + 40, book_y + 55],
802
+ fill=(139, 69, 19), outline=(100, 50, 10), width=2)
803
+ draw.line([book_x + 20, book_y, book_x + 20, book_y + 55], fill=(80, 40, 10), width=2)
804
+
805
+ elif prop == "briefcase":
806
+ bc_x = x + 70
807
+ bc_y = head_y + 130
808
+ draw.rectangle([bc_x, bc_y, bc_x + 50, bc_y + 35],
809
+ fill=(60, 40, 30), outline=(40, 25, 15), width=2)
810
+ draw.rectangle([bc_x + 15, bc_y - 8, bc_x + 35, bc_y + 5],
811
+ fill=(60, 40, 30), outline=(40, 25, 15), width=2)
812
+
813
+ elif prop == "phone":
814
+ phone_x = x + 75
815
+ phone_y = head_y + 80
816
+ draw.rectangle([phone_x, phone_y, phone_x + 25, phone_y + 40],
817
+ fill=(50, 50, 50), outline=(30, 30, 30), width=2)
818
+ draw.rectangle([phone_x + 3, phone_y + 5, phone_x + 22, phone_y + 32],
819
+ fill=(100, 150, 200))
820
+
821
+ # ==========================================
822
+ # VISUAL METAPHORS
823
+ # ==========================================
824
+
825
+ def draw_metaphor(
826
+ self,
827
+ draw: ImageDraw,
828
+ metaphor: str,
829
+ state: str = "intact",
830
+ y_pos: int = None
831
+ ):
832
+ """Draw visual metaphors"""
833
+ y = y_pos or self.HEIGHT // 2 + 200
834
+
835
+ if metaphor == "bridge":
836
+ self._draw_bridge(draw, y, state)
837
+ elif metaphor == "rope":
838
+ self._draw_rope(draw, y, state)
839
+ elif metaphor == "scale":
840
+ self._draw_scale(draw, y, state)
841
+ elif metaphor == "handshake":
842
+ self._draw_handshake(draw, y)
843
+
844
+ def _draw_bridge(self, draw, y, state):
845
+ """Draw bridge metaphor"""
846
+ bridge_y = y
847
+
848
+ if state == "intact":
849
+ # Full bridge
850
+ draw.rectangle([200, bridge_y, 880, bridge_y + 30],
851
+ fill=(139, 90, 43), outline=(100, 65, 30), width=2)
852
+ # Supports
853
+ draw.rectangle([200, bridge_y + 30, 250, bridge_y + 150], fill=(100, 65, 30))
854
+ draw.rectangle([830, bridge_y + 30, 880, bridge_y + 150], fill=(100, 65, 30))
855
+
856
+ elif state == "breaking":
857
+ # Left part
858
+ draw.polygon([
859
+ (200, bridge_y), (500, bridge_y), (520, bridge_y + 80),
860
+ (200, bridge_y + 30)
861
+ ], fill=(139, 90, 43), outline=(100, 65, 30))
862
+ # Right part
863
+ draw.polygon([
864
+ (560, bridge_y + 60), (880, bridge_y), (880, bridge_y + 30),
865
+ (580, bridge_y + 90)
866
+ ], fill=(139, 90, 43), outline=(100, 65, 30))
867
+ # Supports
868
+ draw.rectangle([200, bridge_y + 30, 250, bridge_y + 150], fill=(100, 65, 30))
869
+ draw.rectangle([830, bridge_y + 30, 880, bridge_y + 150], fill=(100, 65, 30))
870
+
871
+ def _draw_rope(self, draw, y, state):
872
+ """Draw rope/tug-of-war metaphor"""
873
+ rope_y = y
874
+
875
+ # Rope
876
+ if state == "tense":
877
+ # Straight tense rope
878
+ draw.line([200, rope_y, 880, rope_y], fill=(139, 90, 43), width=8)
879
+ else:
880
+ # Slight curve
881
+ for i in range(20):
882
+ x1 = 200 + i * 34
883
+ x2 = x1 + 34
884
+ y1 = rope_y + math.sin(i * 0.5) * 10
885
+ y2 = rope_y + math.sin((i + 1) * 0.5) * 10
886
+ draw.line([x1, y1, x2, y2], fill=(139, 90, 43), width=8)
887
+
888
+ # Center marker
889
+ draw.rectangle([530, rope_y - 15, 550, rope_y + 15], fill=self.HIGHLIGHT)
890
+
891
+ def _draw_scale(self, draw, y, state):
892
+ """Draw balance scale metaphor"""
893
+ center_x = self.WIDTH // 2
894
+ base_y = y + 100
895
+
896
+ # Base
897
+ draw.polygon([
898
+ (center_x - 50, base_y), (center_x + 50, base_y),
899
+ (center_x + 30, base_y + 40), (center_x - 30, base_y + 40)
900
+ ], fill=(139, 90, 43))
901
+
902
+ # Pole
903
+ draw.rectangle([center_x - 5, y - 50, center_x + 5, base_y], fill=(100, 65, 30))
904
+
905
+ # Beam
906
+ if state == "balanced":
907
+ draw.rectangle([center_x - 150, y - 55, center_x + 150, y - 45], fill=(100, 65, 30))
908
+ # Plates
909
+ draw.ellipse([center_x - 180, y - 30, center_x - 100, y], fill=(180, 150, 120))
910
+ draw.ellipse([center_x + 100, y - 30, center_x + 180, y], fill=(180, 150, 120))
911
+ else:
912
+ # Tilted beam
913
+ draw.polygon([
914
+ (center_x - 150, y - 30), (center_x + 150, y - 70),
915
+ (center_x + 150, y - 60), (center_x - 150, y - 20)
916
+ ], fill=(100, 65, 30))
917
+
918
+ def _draw_handshake(self, draw, y):
919
+ """Draw handshake"""
920
+ center_x = self.WIDTH // 2
921
+
922
+ # Left hand
923
+ draw.line([center_x - 100, y, center_x - 20, y], fill=self.FIGURE_COLOR, width=10)
924
+ draw.ellipse([center_x - 40, y - 15, center_x, y + 15], fill=self.FIGURE_COLOR)
925
+
926
+ # Right hand
927
+ draw.line([center_x + 100, y, center_x + 20, y], fill=self.FIGURE_COLOR, width=10)
928
+ draw.ellipse([center_x, y - 15, center_x + 40, y + 15], fill=self.FIGURE_COLOR)
929
+
930
+ # Grip
931
+ draw.ellipse([center_x - 15, y - 10, center_x + 15, y + 10], fill=self.FIGURE_COLOR)
932
+
933
+ # ==========================================
934
+ # SCENE RENDERING
935
+ # ==========================================
936
+
937
+ def create_scene_frame(self, scene: Dict) -> Image.Image:
938
+ """Create a complete scene frame"""
939
+ img = Image.new('RGB', (self.WIDTH, self.HEIGHT), self.BG_COLOR)
940
+ draw = ImageDraw.Draw(img)
941
+
942
+ scene_type = scene.get("scene_type", "single")
943
+ background = scene.get("background", "white")
944
+
945
+ # Draw background
946
+ self.draw_background(draw, background)
947
+
948
+ # Title scene
949
+ if scene_type == "title":
950
+ title = scene.get("title_text", "")
951
+ subtitle = scene.get("subtitle_text")
952
+ if title:
953
+ self.draw_title_screen(img, title, subtitle)
954
+
955
+ # Character scenes
956
+ else:
957
+ characters = scene.get("characters", [])
958
+ for char in characters:
959
+ pos = char.get("position", "center")
960
+ x = self.POSITIONS.get(pos, self.POSITIONS["center"])
961
+ y = self.CHARACTER_Y
962
+
963
+ pose = char.get("pose", "standing")
964
+ emotion = char.get("emotion", "happy")
965
+ props = char.get("props", [])
966
+
967
+ self.draw_stick_figure(draw, x, y, pose, emotion, props, scale=1.2)
968
+
969
+ # Thought bubble
970
+ thought = char.get("thought_bubble")
971
+ if thought:
972
+ direction = "right" if pos == "left" else "left"
973
+ head_y = y - self.BODY_LENGTH * 1.2 - self.HEAD_RADIUS * 1.2
974
+ self.draw_thought_bubble(draw, x, int(head_y), thought, direction)
975
+
976
+ # Label
977
+ label = char.get("label")
978
+ if label:
979
+ bbox = draw.textbbox((0, 0), label, font=self.font_label)
980
+ label_width = bbox[2] - bbox[0]
981
+ draw.text((x - label_width // 2, y + 130), label,
982
+ fill=(100, 100, 100), font=self.font_label)
983
+
984
+ # Draw metaphor
985
+ metaphor = scene.get("metaphor")
986
+ if metaphor:
987
+ state = scene.get("metaphor_state", "intact")
988
+ self.draw_metaphor(draw, metaphor, state)
989
+
990
+ # Draw caption
991
+ caption = scene.get("caption")
992
+ if caption:
993
+ self.draw_caption(img, caption)
994
+
995
+ return img
996
+
997
+ def generate_frames_from_scenes(
998
+ self,
999
+ scenes: List[Dict],
1000
+ chunk_durations: List[float],
1001
+ output_dir: str,
1002
+ fps: int = 30
1003
+ ) -> List[str]:
1004
+ """Generate frames for all scenes"""
1005
+ os.makedirs(output_dir, exist_ok=True)
1006
+ frame_paths = []
1007
+ frame_num = 0
1008
+
1009
+ for i, scene in enumerate(scenes):
1010
+ duration = chunk_durations[i] if i < len(chunk_durations) else 2.0
1011
+ num_frames = int(duration * fps)
1012
+
1013
+ logger.info(f"Generating {num_frames} frames for scene {i}: {scene.get('scene_type', 'unknown')}")
1014
+
1015
+ frame = self.create_scene_frame(scene)
1016
+
1017
+ for _ in range(num_frames):
1018
+ frame_path = os.path.join(output_dir, f"frame_{frame_num:05d}.png")
1019
+ frame.save(frame_path)
1020
+ frame_paths.append(frame_path)
1021
+ frame_num += 1
1022
+
1023
+ logger.info(f"Generated {len(frame_paths)} total frames")
1024
+ return frame_paths