ismdrobiul489 commited on
Commit
5c1a3a8
·
1 Parent(s): 61b6896

Remove art_reels module completely - not working properly

Browse files
modules/art_reels/__init__.py DELETED
@@ -1,40 +0,0 @@
1
- """
2
- Art Reels Module - Animated Art Content Generator
3
- Pure Python implementation using Pillow and MoviePy
4
- """
5
- import logging
6
- from config import NCAkitConfig
7
-
8
- logger = logging.getLogger(__name__)
9
-
10
- # Module-level app reference for accessing shared services
11
- _app = None
12
-
13
-
14
- def get_app():
15
- """Get FastAPI app instance"""
16
- return _app
17
-
18
-
19
- def register(app, config: NCAkitConfig):
20
- """Register art_reels module with the app"""
21
- global _app
22
-
23
- try:
24
- from .router import router, set_app_reference
25
-
26
- # Store app reference for accessing TTS/Whisper from app.state
27
- _app = app
28
- set_app_reference(app)
29
-
30
- # Include router
31
- app.include_router(router, prefix="/api/art", tags=["Art Reels"])
32
-
33
- logger.info("Art Reels module registered successfully")
34
- return True
35
-
36
- except Exception as e:
37
- logger.error(f"Failed to register art_reels module: {e}")
38
- import traceback
39
- logger.error(traceback.format_exc())
40
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modules/art_reels/router.py DELETED
@@ -1,536 +0,0 @@
1
- """
2
- Art Reels Module Router
3
- API endpoints for art content generation
4
- """
5
- import logging
6
- import uuid
7
- import asyncio
8
- import os
9
- import shutil
10
- from typing import Dict
11
- from fastapi import APIRouter, HTTPException, BackgroundTasks
12
- from fastapi.responses import FileResponse
13
-
14
- from .schemas import (
15
- MinecraftRequest,
16
- DrawingRequest,
17
- StickFigureRequest,
18
- JobResponse,
19
- JobStatus
20
- )
21
- from .services.block_art import BlockArt
22
- from .services.drawing_animator import DrawingAnimator
23
- from .services.ai_drawing_animator import AIDrawingAnimator
24
- from .services.stick_figure import StickFigure
25
- from .services.video_composer import VideoComposer
26
-
27
- logger = logging.getLogger(__name__)
28
-
29
- router = APIRouter()
30
-
31
- # App reference for accessing shared services (TTS, Whisper)
32
- _app = None
33
-
34
-
35
- def set_app_reference(app):
36
- """Set FastAPI app reference for accessing shared services"""
37
- global _app
38
- _app = app
39
- logger.info("App reference set for art_reels module")
40
-
41
-
42
- # In-memory job storage
43
- jobs: Dict[str, Dict] = {}
44
-
45
- # Initialize services
46
- block_art = BlockArt()
47
- drawing_animator = DrawingAnimator()
48
- stick_figure = StickFigure()
49
- video_composer = VideoComposer(output_dir="videos/art_reels")
50
-
51
-
52
- def update_job(job_id: str, status: str, progress: int = 0,
53
- video_url: str = None, error: str = None):
54
- """Update job status"""
55
- if job_id in jobs:
56
- jobs[job_id].update({
57
- "status": status,
58
- "progress": progress,
59
- "video_url": video_url,
60
- "error": error
61
- })
62
-
63
-
64
- async def generate_minecraft_video(job_id: str, description: str, blocks: int, speed: float):
65
- """Background task to generate Minecraft block art video"""
66
- temp_dir = f"temp/art_{job_id}"
67
-
68
- try:
69
- update_job(job_id, "processing", 10)
70
-
71
- # Generate frames
72
- logger.info(f"Generating Minecraft frames for job {job_id}")
73
- frames_per_block = max(1, int(3 / speed))
74
- frame_paths = block_art.generate_build_animation(
75
- description=description,
76
- output_dir=temp_dir,
77
- blocks_per_frame=frames_per_block
78
- )
79
-
80
- update_job(job_id, "processing", 60)
81
-
82
- # Compose video
83
- logger.info(f"Composing video for job {job_id}")
84
- video_path = video_composer.compose_video(
85
- frame_paths=frame_paths,
86
- output_name=f"minecraft_{job_id}.mp4",
87
- fps=30
88
- )
89
-
90
- update_job(job_id, "processing", 90)
91
-
92
- # Cleanup frames
93
- video_composer.cleanup_frames(frame_paths)
94
- if os.path.exists(temp_dir):
95
- shutil.rmtree(temp_dir)
96
-
97
- update_job(job_id, "ready", 100, video_url=f"/api/art/video/{job_id}")
98
- logger.info(f"Minecraft video ready: {job_id}")
99
-
100
- except Exception as e:
101
- logger.error(f"Error generating Minecraft video: {e}")
102
- update_job(job_id, "failed", error=str(e))
103
-
104
-
105
- async def generate_drawing_video(job_id: str, subject: str, style: str, colors: bool):
106
- """Background task to generate AI-powered drawing animation video"""
107
- temp_dir = f"temp/art_{job_id}"
108
-
109
- try:
110
- update_job(job_id, "processing", 10)
111
-
112
- # Create AI Drawing Animator
113
- ai_drawer = AIDrawingAnimator()
114
-
115
- # Generate frames using AI paths
116
- logger.info(f"Generating AI drawing frames for job {job_id}: '{subject}'")
117
- frame_paths = ai_drawer.generate_animation(
118
- prompt=subject,
119
- output_dir=temp_dir,
120
- frames_per_path=12,
121
- fps=30
122
- )
123
-
124
- update_job(job_id, "processing", 60)
125
-
126
- # Compose video
127
- logger.info(f"Composing video for job {job_id}")
128
- video_path = video_composer.compose_video(
129
- frame_paths=frame_paths,
130
- output_name=f"drawing_{job_id}.mp4",
131
- fps=30
132
- )
133
-
134
- update_job(job_id, "processing", 85)
135
-
136
- # Upload to HF Hub (if enabled)
137
- from pathlib import Path
138
- from modules.shared.services.hf_storage import get_hf_storage
139
-
140
- hf_storage = get_hf_storage()
141
- cloud_url = None
142
-
143
- if hf_storage and hf_storage.enabled:
144
- logger.info(f"Uploading to HF Hub for job {job_id}")
145
- cloud_url = hf_storage.upload_video(
146
- local_path=Path(video_path),
147
- video_id=job_id,
148
- folder="art_reels"
149
- )
150
- if cloud_url:
151
- logger.info(f"Uploaded to cloud: {cloud_url}")
152
-
153
- # Cleanup
154
- video_composer.cleanup_frames(frame_paths)
155
- if os.path.exists(temp_dir):
156
- shutil.rmtree(temp_dir)
157
-
158
- video_url = cloud_url or f"/api/art/video/{job_id}"
159
- update_job(job_id, "ready", 100, video_url=video_url)
160
- logger.info(f"AI Drawing video ready: {job_id}")
161
-
162
- except Exception as e:
163
- logger.error(f"Error generating drawing video: {e}")
164
- import traceback
165
- logger.error(traceback.format_exc())
166
- update_job(job_id, "failed", error=str(e))
167
-
168
-
169
- async def generate_stick_figure_video(job_id: str, script: str, voice: str):
170
- """Background task to generate stick figure motivation video with TTS"""
171
- temp_dir = f"temp/art_{job_id}"
172
- os.makedirs(temp_dir, exist_ok=True)
173
-
174
- try:
175
- update_job(job_id, "processing", 5)
176
- logger.info(f"Starting stick figure video with TTS for job {job_id}")
177
-
178
- # Import TTS, Whisper, and Professional Stick Figure
179
- from ..story_reels.services.srt_parser import SRTParser
180
- from .services.professional_stick_figure import ProfessionalStickFigure
181
-
182
- # Get TTS and Whisper clients from app.state (already initialized by story_reels)
183
- if not _app or not hasattr(_app, 'state'):
184
- raise Exception("App reference not available")
185
-
186
- tts_client = getattr(_app.state, 'tts_client', None)
187
- whisper_client = getattr(_app.state, 'whisper_client', None)
188
-
189
- if not tts_client:
190
- raise Exception("TTS client not available - check if story_reels module is loaded")
191
-
192
- if not whisper_client:
193
- raise Exception("Whisper client not available - check if story_reels module is loaded")
194
-
195
- logger.info("Using TTS and Whisper clients from app.state")
196
-
197
- update_job(job_id, "processing", 10)
198
-
199
- # Step 1: Generate TTS audio from script
200
- logger.info(f"Generating TTS audio for job {job_id}")
201
- audio_path = os.path.join(temp_dir, "voice.wav")
202
-
203
- # TTS generate is async and returns (audio_bytes, duration)
204
- audio_bytes, audio_duration = await tts_client.generate(text=script, voice=voice)
205
-
206
- # Save audio to file
207
- with open(audio_path, "wb") as f:
208
- f.write(audio_bytes)
209
-
210
- logger.info(f"TTS audio saved: {audio_path}, duration: {audio_duration:.2f}s")
211
-
212
- if not os.path.exists(audio_path):
213
- raise Exception("TTS audio generation failed")
214
-
215
- update_job(job_id, "processing", 30)
216
-
217
- # Step 2: Generate captions with Whisper (exact story_reels workflow)
218
- logger.info(f"Generating captions with Whisper for job {job_id}")
219
-
220
- # WhisperClient.create_captions returns List[Caption]
221
- captions = await asyncio.to_thread(whisper_client.create_captions, audio_path)
222
-
223
- # Convert to dict format
224
- captions_dict = [c.dict() for c in captions]
225
-
226
- # Generate .srt content (for video subtitles)
227
- srt_content = SRTParser.generate_srt_content(captions_dict)
228
- srt_path = os.path.join(temp_dir, "voice.srt")
229
- with open(srt_path, "w", encoding="utf-8") as f:
230
- f.write(srt_content)
231
- logger.info(f"Generated .srt with {len(captions)} captions")
232
-
233
- # Get actual audio duration
234
- from modules.video_creator.services.libraries.ffmpeg_utils import FFmpegUtils
235
- audio_duration = FFmpegUtils.get_video_duration(audio_path)
236
-
237
- # Step 3: Create 2-second chunks (for AI scene generation)
238
- chunks = SRTParser.create_2s_chunks(captions_dict, audio_duration)
239
- logger.info(f"Created {len(chunks)} x 2s chunks for job {job_id}")
240
-
241
- update_job(job_id, "processing", 45)
242
-
243
- # Step 4: Generate scenes with AI (Professional)
244
- pro_stick = ProfessionalStickFigure()
245
- scenes = pro_stick.generate_scenes_with_ai(chunks)
246
-
247
- # Log scenes for debugging
248
- logger.info(f"AI generated {len(scenes)} scenes for {len(chunks)} chunks")
249
- for i, s in enumerate(scenes):
250
- scene_info = f"Scene {i}: type={s.get('scene_type', 'unknown')}"
251
- if s.get('characters'):
252
- poses = [c.get('pose', 'unknown') for c in s['characters']]
253
- scene_info += f", poses={poses}"
254
- logger.info(scene_info)
255
-
256
- # Log chunks for debugging
257
- logger.info(f"Chunks from SRTParser: {len(chunks)}")
258
- for i, c in enumerate(chunks):
259
- logger.info(f"Chunk {i}: text='{c.get('text', '')[:30]}...', duration={c.get('duration', 2.0)}s")
260
-
261
- # Calculate durations for each chunk (SRTParser provides 'duration' in seconds)
262
- chunk_durations = []
263
- for chunk in chunks:
264
- # SRTParser.create_2s_chunks returns 'duration' key in seconds
265
- duration = chunk.get('duration', 2.0)
266
- chunk_durations.append(max(0.5, duration))
267
-
268
- update_job(job_id, "processing", 60)
269
-
270
- # Step 5: Generate frames
271
- logger.info(f"Generating stick figure frames for job {job_id}")
272
- frame_paths = pro_stick.generate_frames_from_scenes(
273
- scenes=scenes,
274
- chunk_durations=chunk_durations,
275
- output_dir=temp_dir,
276
- fps=30
277
- )
278
-
279
- update_job(job_id, "processing", 80)
280
-
281
- # Step 6: Compose video with audio and captions
282
- logger.info(f"Composing video with audio for job {job_id}")
283
- video_path = video_composer.compose_video(
284
- frame_paths=frame_paths,
285
- audio_path=audio_path,
286
- output_name=f"stick_{job_id}.mp4",
287
- fps=30,
288
- captions=captions_dict # Word-by-word captions from Whisper
289
- )
290
-
291
- update_job(job_id, "processing", 95)
292
-
293
- # Step 7: Upload to HF Hub (if enabled)
294
- from pathlib import Path
295
- from modules.shared.services.hf_storage import get_hf_storage
296
-
297
- hf_storage = get_hf_storage()
298
- cloud_url = None
299
-
300
- if hf_storage and hf_storage.enabled:
301
- logger.info(f"Uploading to HF Hub for job {job_id}")
302
- cloud_url = hf_storage.upload_video(
303
- local_path=Path(video_path),
304
- video_id=job_id,
305
- folder="art_reels"
306
- )
307
- if cloud_url:
308
- logger.info(f"Uploaded to cloud: {cloud_url}")
309
-
310
- # Cleanup
311
- video_composer.cleanup_frames(frame_paths)
312
- if os.path.exists(temp_dir):
313
- shutil.rmtree(temp_dir)
314
-
315
- # Set video URL (cloud if available, otherwise local)
316
- video_url = cloud_url or f"/api/art/video/{job_id}"
317
- update_job(job_id, "ready", 100, video_url=video_url)
318
- logger.info(f"Stick figure video with TTS ready: {job_id}")
319
-
320
- except Exception as e:
321
- logger.error(f"Error generating stick figure video: {e}")
322
- import traceback
323
- logger.error(traceback.format_exc())
324
- update_job(job_id, "failed", error=str(e))
325
-
326
-
327
- def parse_script_to_scenes(script: str) -> list:
328
- """Parse script text into scenes with poses and keywords"""
329
- scenes = []
330
-
331
- # Split into sentences
332
- sentences = [s.strip() for s in script.replace("।", ".").split(".") if s.strip()]
333
-
334
- # Keyword to pose/prop mapping
335
- keyword_mapping = {
336
- # Poses
337
- "ঘুম": ("sleeping", []),
338
- "sleep": ("sleeping", []),
339
- "দৌড়": ("running", []),
340
- "run": ("running", []),
341
- "হাঁট": ("walking", []),
342
- "walk": ("walking", []),
343
- "বস": ("sitting", []),
344
- "sit": ("sitting", []),
345
- "ভাব": ("thinking", []),
346
- "think": ("thinking", []),
347
- # Props
348
- "রাজা": ("standing", ["crown"]),
349
- "king": ("standing", ["crown"]),
350
- "বড়লোক": ("standing", ["money", "suit"]),
351
- "rich": ("standing", ["money", "suit"]),
352
- "টাকা": ("standing", ["money"]),
353
- "money": ("standing", ["money"]),
354
- }
355
-
356
- for sentence in sentences:
357
- sentence_lower = sentence.lower()
358
- pose = "standing"
359
- props = []
360
-
361
- # Check for keywords
362
- for keyword, (detected_pose, detected_props) in keyword_mapping.items():
363
- if keyword in sentence_lower:
364
- pose = detected_pose
365
- props.extend(detected_props)
366
- break
367
-
368
- # Check for text overlay triggers
369
- text = None
370
- text_triggers = ["get ready", "remember", "important", "key point", "মনে রাখ"]
371
- for trigger in text_triggers:
372
- if trigger in sentence_lower:
373
- text = sentence[:50] + "..." if len(sentence) > 50 else sentence
374
-
375
- scenes.append({
376
- "pose": pose,
377
- "props": list(set(props)),
378
- "text": text,
379
- "duration": max(1.5, len(sentence) / 20) # ~20 chars per second
380
- })
381
-
382
- return scenes
383
-
384
-
385
- # ===================
386
- # API Endpoints
387
- # ===================
388
-
389
- @router.post("/minecraft",
390
- response_model=JobResponse,
391
- summary="Generate Minecraft block art video",
392
- description="Create a Minecraft-style block-by-block building animation"
393
- )
394
- async def create_minecraft_video(request: MinecraftRequest, background_tasks: BackgroundTasks):
395
- """Generate Minecraft block art video"""
396
- job_id = uuid.uuid4().hex[:12]
397
-
398
- jobs[job_id] = {
399
- "job_id": job_id,
400
- "type": "minecraft",
401
- "status": "queued",
402
- "progress": 0,
403
- "video_url": None,
404
- "error": None
405
- }
406
-
407
- background_tasks.add_task(
408
- generate_minecraft_video,
409
- job_id,
410
- request.description,
411
- request.blocks,
412
- request.speed
413
- )
414
-
415
- return JobResponse(
416
- job_id=job_id,
417
- status="queued",
418
- message="Minecraft video generation started"
419
- )
420
-
421
-
422
- @router.post("/drawing",
423
- response_model=JobResponse,
424
- summary="Generate code drawing animation",
425
- description="Create a line-by-line drawing animation video"
426
- )
427
- async def create_drawing_video(request: DrawingRequest, background_tasks: BackgroundTasks):
428
- """Generate code drawing animation video"""
429
- job_id = uuid.uuid4().hex[:12]
430
-
431
- jobs[job_id] = {
432
- "job_id": job_id,
433
- "type": "drawing",
434
- "status": "queued",
435
- "progress": 0,
436
- "video_url": None,
437
- "error": None
438
- }
439
-
440
- background_tasks.add_task(
441
- generate_drawing_video,
442
- job_id,
443
- request.subject,
444
- request.style.value,
445
- request.colors
446
- )
447
-
448
- return JobResponse(
449
- job_id=job_id,
450
- status="queued",
451
- message="Drawing video generation started"
452
- )
453
-
454
-
455
- @router.post("/stick-figure",
456
- response_model=JobResponse,
457
- summary="Generate stick figure motivation video",
458
- description="Create a stick figure motivation/story video"
459
- )
460
- async def create_stick_figure_video(request: StickFigureRequest, background_tasks: BackgroundTasks):
461
- """Generate stick figure motivation video"""
462
- job_id = uuid.uuid4().hex[:12]
463
-
464
- jobs[job_id] = {
465
- "job_id": job_id,
466
- "type": "stick_figure",
467
- "status": "queued",
468
- "progress": 0,
469
- "video_url": None,
470
- "error": None
471
- }
472
-
473
- background_tasks.add_task(
474
- generate_stick_figure_video,
475
- job_id,
476
- request.script,
477
- request.voice
478
- )
479
-
480
- return JobResponse(
481
- job_id=job_id,
482
- status="queued",
483
- message="Stick figure video generation started"
484
- )
485
-
486
-
487
- @router.get("/{job_id}/status",
488
- response_model=JobStatus,
489
- summary="Get job status",
490
- description="Check the status of a video generation job"
491
- )
492
- async def get_job_status(job_id: str):
493
- """Get video generation job status"""
494
- if job_id not in jobs:
495
- raise HTTPException(status_code=404, detail="Job not found")
496
-
497
- job = jobs[job_id]
498
- return JobStatus(**job)
499
-
500
-
501
- @router.get("/video/{job_id}",
502
- summary="Download video",
503
- description="Download the generated video file"
504
- )
505
- async def download_video(job_id: str):
506
- """Download generated video"""
507
- if job_id not in jobs:
508
- raise HTTPException(status_code=404, detail="Job not found")
509
-
510
- job = jobs[job_id]
511
- if job["status"] != "ready":
512
- raise HTTPException(status_code=400, detail=f"Video not ready. Status: {job['status']}")
513
-
514
- # Find video file
515
- video_dir = "videos/art_reels"
516
- for filename in os.listdir(video_dir):
517
- if job_id in filename:
518
- video_path = os.path.join(video_dir, filename)
519
- return FileResponse(
520
- video_path,
521
- media_type="video/mp4",
522
- filename=filename
523
- )
524
-
525
- raise HTTPException(status_code=404, detail="Video file not found")
526
-
527
-
528
- @router.get("/templates",
529
- summary="List drawing templates",
530
- description="Get available drawing templates"
531
- )
532
- async def list_templates():
533
- """List available drawing templates"""
534
- return {
535
- "templates": list(DrawingAnimator.TEMPLATES.keys())
536
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modules/art_reels/schemas.py DELETED
@@ -1,84 +0,0 @@
1
- """
2
- Art Reels Module Schemas
3
- Request/Response models for art content generation
4
- """
5
- from pydantic import BaseModel, Field
6
- from typing import Optional, List
7
- from enum import Enum
8
-
9
-
10
- # ===================
11
- # Enums
12
- # ===================
13
-
14
- class BlockTypeEnum(str, Enum):
15
- """Minecraft block types"""
16
- oak_wood = "oak_wood"
17
- stone = "stone"
18
- cobblestone = "cobblestone"
19
- grass = "grass"
20
- dirt = "dirt"
21
- glass = "glass"
22
- brick = "brick"
23
-
24
-
25
- class DrawingStyleEnum(str, Enum):
26
- """Drawing animation styles"""
27
- outline_first = "outline_first"
28
- color_fill = "color_fill"
29
- sketch = "sketch"
30
-
31
-
32
- class StickPoseEnum(str, Enum):
33
- """Stick figure poses"""
34
- standing = "standing"
35
- walking = "walking"
36
- running = "running"
37
- sitting = "sitting"
38
- sleeping = "sleeping"
39
- waving = "waving"
40
- thinking = "thinking"
41
-
42
-
43
- # ===================
44
- # Request Models
45
- # ===================
46
-
47
- class MinecraftRequest(BaseModel):
48
- """Request for Minecraft block art video"""
49
- description: str = Field(..., description="What to build, e.g., 'wooden survival house'")
50
- blocks: int = Field(50, ge=10, le=200, description="Approximate number of blocks")
51
- speed: float = Field(1.0, ge=0.5, le=3.0, description="Animation speed multiplier")
52
-
53
-
54
- class DrawingRequest(BaseModel):
55
- """Request for code drawing animation"""
56
- subject: str = Field(..., description="What to draw, e.g., 'a house', 'a tree'")
57
- style: DrawingStyleEnum = Field(DrawingStyleEnum.outline_first, description="Drawing style")
58
- colors: bool = Field(True, description="Add colors after outline")
59
-
60
-
61
- class StickFigureRequest(BaseModel):
62
- """Request for stick figure motivation video"""
63
- script: str = Field(..., description="Voice script text")
64
- voice: str = Field("af_heart", description="Voice ID for TTS")
65
-
66
-
67
- # ===================
68
- # Response Models
69
- # ===================
70
-
71
- class JobResponse(BaseModel):
72
- """Job creation response"""
73
- job_id: str
74
- status: str
75
- message: str
76
-
77
-
78
- class JobStatus(BaseModel):
79
- """Job status response"""
80
- job_id: str
81
- status: str
82
- progress: int
83
- video_url: Optional[str] = None
84
- error: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modules/art_reels/services/__init__.py DELETED
@@ -1 +0,0 @@
1
- # Services init
 
 
modules/art_reels/services/ai_drawing_animator.py DELETED
@@ -1,535 +0,0 @@
1
- """
2
- AI Drawing Animator - Dynamic SVG Path Generation with AI
3
- Generates complex drawings from any prompt using AI-generated paths
4
- """
5
- import logging
6
- import os
7
- import json
8
- import math
9
- import re
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
- # AI System Prompt for SVG path generation
18
- DRAWING_PROMPT = """You are an expert SVG artist. Generate detailed drawing paths for any subject.
19
-
20
- OUTPUT FORMAT (JSON):
21
- {
22
- "title": "Drawing Name",
23
- "paths": [
24
- {
25
- "type": "line",
26
- "start": [x1, y1],
27
- "end": [x2, y2],
28
- "color": "#000000",
29
- "width": 3,
30
- "order": 1
31
- },
32
- {
33
- "type": "circle",
34
- "center": [cx, cy],
35
- "radius": r,
36
- "color": "#FF0000",
37
- "fill": true,
38
- "order": 2
39
- },
40
- {
41
- "type": "polygon",
42
- "points": [[x1,y1], [x2,y2], [x3,y3]...],
43
- "color": "#00FF00",
44
- "fill": true,
45
- "order": 3
46
- },
47
- {
48
- "type": "arc",
49
- "center": [cx, cy],
50
- "radius": r,
51
- "start_angle": 0,
52
- "end_angle": 180,
53
- "color": "#0000FF",
54
- "width": 2,
55
- "order": 4
56
- },
57
- {
58
- "type": "bezier",
59
- "points": [[x1,y1], [x2,y2], [x3,y3], [x4,y4]],
60
- "color": "#000000",
61
- "width": 2,
62
- "order": 5
63
- }
64
- ]
65
- }
66
-
67
- CANVAS SIZE: 1080 x 1920 (9:16 portrait)
68
- CENTER: (540, 960)
69
- DRAWING AREA: (100, 300) to (980, 1600)
70
-
71
- RULES:
72
- 1. Create DETAILED paths - at least 15-30 elements for complex subjects
73
- 2. Use logical drawing order (background → foreground)
74
- 3. Include both outline (lines) and fill (polygons/circles)
75
- 4. Use varied line widths (2-8) for depth
76
- 5. Keep everything within the drawing area
77
- 6. Use colors that make sense for the subject
78
- 7. For complex shapes, break into multiple simple paths
79
-
80
- EXAMPLES:
81
- - "car" → wheels (circles), body (polygon), windows (rectangles), details (lines)
82
- - "face" → head (circle), eyes (circles), nose (lines), mouth (arc), hair (beziers)
83
- - "tree" → trunk (rectangle), branches (lines), leaves (many small circles/polygons)
84
- - "house" → walls (polygons), roof (polygon), windows (rectangles), door (rectangle)
85
-
86
- Return ONLY valid JSON, no other text."""
87
-
88
-
89
- class AIDrawingAnimator:
90
- """
91
- AI-powered drawing animator that generates complex SVG-like paths
92
- from any text prompt and animates them line-by-line.
93
- """
94
-
95
- # Canvas dimensions
96
- WIDTH = 1080
97
- HEIGHT = 1920
98
-
99
- # Default colors
100
- BG_COLOR = (255, 255, 255)
101
- LINE_COLOR = (30, 30, 30)
102
-
103
- def __init__(self, groq_api_key: str = None):
104
- self.groq_api_key = groq_api_key or os.environ.get("GROQ_API") or os.environ.get("GROQ_API_KEY")
105
- if self.groq_api_key:
106
- self.groq = Groq(api_key=self.groq_api_key)
107
- logger.info("AI Drawing Animator initialized with Groq")
108
- else:
109
- self.groq = None
110
- logger.warning("Groq API key not found - using fallback templates")
111
-
112
- self._load_fonts()
113
-
114
- def _load_fonts(self):
115
- """Load fonts with fallbacks"""
116
- try:
117
- self.font_title = ImageFont.truetype("arial.ttf", 48)
118
- self.font_label = ImageFont.truetype("arial.ttf", 32)
119
- except:
120
- self.font_title = ImageFont.load_default()
121
- self.font_label = ImageFont.load_default()
122
-
123
- def generate_paths_with_ai(self, prompt: str) -> Dict:
124
- """Generate drawing paths using AI"""
125
- if not self.groq:
126
- return self._get_fallback_drawing(prompt)
127
-
128
- try:
129
- user_prompt = f"""Create a detailed drawing of: "{prompt}"
130
-
131
- Generate all the paths needed to draw this from scratch, line by line.
132
- Make it detailed and professional-looking.
133
- Return ONLY valid JSON."""
134
-
135
- response = self.groq.chat.completions.create(
136
- model="llama-3.3-70b-versatile",
137
- messages=[
138
- {"role": "system", "content": DRAWING_PROMPT},
139
- {"role": "user", "content": user_prompt}
140
- ],
141
- temperature=0.7,
142
- max_tokens=4000
143
- )
144
-
145
- content = response.choices[0].message.content.strip()
146
-
147
- # Parse JSON
148
- if "```" in content:
149
- content = content.split("```")[1]
150
- if content.startswith("json"):
151
- content = content[4:]
152
- content = content.strip()
153
-
154
- drawing = json.loads(content)
155
- logger.info(f"AI generated {len(drawing.get('paths', []))} paths for '{prompt}'")
156
- return drawing
157
-
158
- except Exception as e:
159
- logger.error(f"AI path generation failed: {e}")
160
- return self._get_fallback_drawing(prompt)
161
-
162
- def _get_fallback_drawing(self, prompt: str) -> Dict:
163
- """Fallback templates for common subjects"""
164
- prompt_lower = prompt.lower()
165
-
166
- # Detect subject and return appropriate template
167
- if any(word in prompt_lower for word in ["car", "vehicle", "auto"]):
168
- return self._car_template()
169
- elif any(word in prompt_lower for word in ["face", "person", "human", "head"]):
170
- return self._face_template()
171
- elif any(word in prompt_lower for word in ["tree", "plant", "forest"]):
172
- return self._tree_template()
173
- elif any(word in prompt_lower for word in ["house", "home", "building"]):
174
- return self._house_template()
175
- elif any(word in prompt_lower for word in ["flower", "rose", "tulip"]):
176
- return self._flower_template()
177
- elif any(word in prompt_lower for word in ["star", "stars"]):
178
- return self._star_template()
179
- elif any(word in prompt_lower for word in ["heart", "love"]):
180
- return self._heart_template()
181
- elif any(word in prompt_lower for word in ["sun", "sunrise"]):
182
- return self._sun_template()
183
- else:
184
- # Default to a simple abstract pattern
185
- return self._abstract_template(prompt)
186
-
187
- def _car_template(self) -> Dict:
188
- """Detailed car template"""
189
- return {
190
- "title": "Sports Car",
191
- "paths": [
192
- # Car body
193
- {"type": "polygon", "points": [[200, 900], [880, 900], [880, 750], [720, 750], [650, 650], [350, 650], [280, 750], [200, 750]], "color": "#E74C3C", "fill": True, "order": 1},
194
- # Windows
195
- {"type": "polygon", "points": [[360, 740], [640, 740], [600, 660], [380, 660]], "color": "#AED6F1", "fill": True, "order": 2},
196
- # Front wheel
197
- {"type": "circle", "center": [300, 900], "radius": 60, "color": "#2C3E50", "fill": True, "order": 3},
198
- {"type": "circle", "center": [300, 900], "radius": 35, "color": "#95A5A6", "fill": True, "order": 4},
199
- # Back wheel
200
- {"type": "circle", "center": [780, 900], "radius": 60, "color": "#2C3E50", "fill": True, "order": 5},
201
- {"type": "circle", "center": [780, 900], "radius": 35, "color": "#95A5A6", "fill": True, "order": 6},
202
- # Headlight
203
- {"type": "circle", "center": [850, 800], "radius": 20, "color": "#F1C40F", "fill": True, "order": 7},
204
- # Door line
205
- {"type": "line", "start": [500, 750], "end": [500, 900], "color": "#C0392B", "width": 3, "order": 8},
206
- # Door handle
207
- {"type": "line", "start": [440, 800], "end": [480, 800], "color": "#2C3E50", "width": 4, "order": 9},
208
- # Ground
209
- {"type": "line", "start": [100, 960], "end": [980, 960], "color": "#7F8C8D", "width": 5, "order": 10},
210
- ]
211
- }
212
-
213
- def _face_template(self) -> Dict:
214
- """Detailed face template"""
215
- cx, cy = 540, 700
216
- return {
217
- "title": "Human Face",
218
- "paths": [
219
- # Head outline
220
- {"type": "circle", "center": [cx, cy], "radius": 200, "color": "#FDEBD0", "fill": True, "order": 1},
221
- {"type": "circle", "center": [cx, cy], "radius": 200, "color": "#2C3E50", "fill": False, "order": 2},
222
- # Left eye
223
- {"type": "circle", "center": [cx-60, cy-40], "radius": 35, "color": "#FFFFFF", "fill": True, "order": 3},
224
- {"type": "circle", "center": [cx-60, cy-40], "radius": 35, "color": "#2C3E50", "fill": False, "order": 4},
225
- {"type": "circle", "center": [cx-60, cy-40], "radius": 15, "color": "#2C3E50", "fill": True, "order": 5},
226
- # Right eye
227
- {"type": "circle", "center": [cx+60, cy-40], "radius": 35, "color": "#FFFFFF", "fill": True, "order": 6},
228
- {"type": "circle", "center": [cx+60, cy-40], "radius": 35, "color": "#2C3E50", "fill": False, "order": 7},
229
- {"type": "circle", "center": [cx+60, cy-40], "radius": 15, "color": "#2C3E50", "fill": True, "order": 8},
230
- # Nose
231
- {"type": "line", "start": [cx, cy-20], "end": [cx, cy+40], "color": "#D35400", "width": 3, "order": 9},
232
- {"type": "line", "start": [cx, cy+40], "end": [cx-15, cy+50], "color": "#D35400", "width": 3, "order": 10},
233
- # Smile
234
- {"type": "arc", "center": [cx, cy+60], "radius": 60, "start_angle": 10, "end_angle": 170, "color": "#E74C3C", "width": 5, "order": 11},
235
- # Eyebrows
236
- {"type": "line", "start": [cx-90, cy-90], "end": [cx-30, cy-85], "color": "#2C3E50", "width": 4, "order": 12},
237
- {"type": "line", "start": [cx+30, cy-85], "end": [cx+90, cy-90], "color": "#2C3E50", "width": 4, "order": 13},
238
- # Hair
239
- {"type": "arc", "center": [cx, cy-100], "radius": 180, "start_angle": 200, "end_angle": 340, "color": "#5D4037", "width": 40, "order": 14},
240
- ]
241
- }
242
-
243
- def _tree_template(self) -> Dict:
244
- """Detailed tree template"""
245
- return {
246
- "title": "Tree",
247
- "paths": [
248
- # Trunk
249
- {"type": "polygon", "points": [[500, 1200], [580, 1200], [570, 900], [510, 900]], "color": "#795548", "fill": True, "order": 1},
250
- # Main branches
251
- {"type": "line", "start": [540, 900], "end": [400, 700], "color": "#5D4037", "width": 15, "order": 2},
252
- {"type": "line", "start": [540, 900], "end": [680, 700], "color": "#5D4037", "width": 15, "order": 3},
253
- {"type": "line", "start": [540, 850], "end": [540, 600], "color": "#5D4037", "width": 12, "order": 4},
254
- # Leaves (circles)
255
- {"type": "circle", "center": [400, 650], "radius": 80, "color": "#27AE60", "fill": True, "order": 5},
256
- {"type": "circle", "center": [540, 550], "radius": 90, "color": "#2ECC71", "fill": True, "order": 6},
257
- {"type": "circle", "center": [680, 650], "radius": 80, "color": "#27AE60", "fill": True, "order": 7},
258
- {"type": "circle", "center": [470, 580], "radius": 70, "color": "#1E8449", "fill": True, "order": 8},
259
- {"type": "circle", "center": [610, 580], "radius": 70, "color": "#1E8449", "fill": True, "order": 9},
260
- {"type": "circle", "center": [540, 450], "radius": 60, "color": "#2ECC71", "fill": True, "order": 10},
261
- # Ground
262
- {"type": "line", "start": [200, 1200], "end": [880, 1200], "color": "#7F8C8D", "width": 3, "order": 11},
263
- ]
264
- }
265
-
266
- def _house_template(self) -> Dict:
267
- """Detailed house template"""
268
- return {
269
- "title": "House",
270
- "paths": [
271
- # Main wall
272
- {"type": "polygon", "points": [[250, 1100], [830, 1100], [830, 650], [250, 650]], "color": "#F5DEB3", "fill": True, "order": 1},
273
- # Roof
274
- {"type": "polygon", "points": [[200, 650], [540, 400], [880, 650]], "color": "#8B4513", "fill": True, "order": 2},
275
- # Door
276
- {"type": "polygon", "points": [[480, 1100], [600, 1100], [600, 850], [480, 850]], "color": "#5D4037", "fill": True, "order": 3},
277
- # Door handle
278
- {"type": "circle", "center": [570, 975], "radius": 10, "color": "#FFD700", "fill": True, "order": 4},
279
- # Left window
280
- {"type": "polygon", "points": [[300, 750], [420, 750], [420, 880], [300, 880]], "color": "#AED6F1", "fill": True, "order": 5},
281
- {"type": "line", "start": [360, 750], "end": [360, 880], "color": "#FFFFFF", "width": 3, "order": 6},
282
- {"type": "line", "start": [300, 815], "end": [420, 815], "color": "#FFFFFF", "width": 3, "order": 7},
283
- # Right window
284
- {"type": "polygon", "points": [[660, 750], [780, 750], [780, 880], [660, 880]], "color": "#AED6F1", "fill": True, "order": 8},
285
- {"type": "line", "start": [720, 750], "end": [720, 880], "color": "#FFFFFF", "width": 3, "order": 9},
286
- {"type": "line", "start": [660, 815], "end": [780, 815], "color": "#FFFFFF", "width": 3, "order": 10},
287
- # Chimney
288
- {"type": "polygon", "points": [[700, 550], [760, 550], [760, 430], [700, 430]], "color": "#A0522D", "fill": True, "order": 11},
289
- # Ground
290
- {"type": "line", "start": [100, 1100], "end": [980, 1100], "color": "#228B22", "width": 8, "order": 12},
291
- ]
292
- }
293
-
294
- def _flower_template(self) -> Dict:
295
- """Flower template"""
296
- cx, cy = 540, 700
297
- return {
298
- "title": "Flower",
299
- "paths": [
300
- # Stem
301
- {"type": "line", "start": [cx, cy+100], "end": [cx, cy+400], "color": "#27AE60", "width": 8, "order": 1},
302
- # Leaves
303
- {"type": "polygon", "points": [[cx, cy+250], [cx-80, cy+300], [cx-40, cy+350], [cx, cy+300]], "color": "#2ECC71", "fill": True, "order": 2},
304
- {"type": "polygon", "points": [[cx, cy+200], [cx+80, cy+250], [cx+40, cy+300], [cx, cy+250]], "color": "#2ECC71", "fill": True, "order": 3},
305
- # Petals
306
- {"type": "circle", "center": [cx, cy-80], "radius": 50, "color": "#E74C3C", "fill": True, "order": 4},
307
- {"type": "circle", "center": [cx-70, cy-30], "radius": 50, "color": "#E74C3C", "fill": True, "order": 5},
308
- {"type": "circle", "center": [cx+70, cy-30], "radius": 50, "color": "#E74C3C", "fill": True, "order": 6},
309
- {"type": "circle", "center": [cx-45, cy+50], "radius": 50, "color": "#E74C3C", "fill": True, "order": 7},
310
- {"type": "circle", "center": [cx+45, cy+50], "radius": 50, "color": "#E74C3C", "fill": True, "order": 8},
311
- # Center
312
- {"type": "circle", "center": [cx, cy], "radius": 40, "color": "#F1C40F", "fill": True, "order": 9},
313
- ]
314
- }
315
-
316
- def _star_template(self) -> Dict:
317
- """5-point star template"""
318
- cx, cy = 540, 700
319
- # Calculate star points
320
- outer_r = 200
321
- inner_r = 80
322
- points = []
323
- for i in range(10):
324
- angle = math.pi/2 + i * math.pi/5
325
- r = outer_r if i % 2 == 0 else inner_r
326
- x = cx + r * math.cos(angle)
327
- y = cy - r * math.sin(angle)
328
- points.append([int(x), int(y)])
329
-
330
- return {
331
- "title": "Star",
332
- "paths": [
333
- {"type": "polygon", "points": points, "color": "#F1C40F", "fill": True, "order": 1},
334
- {"type": "polygon", "points": points, "color": "#D4AC0D", "fill": False, "order": 2},
335
- ]
336
- }
337
-
338
- def _heart_template(self) -> Dict:
339
- """Heart shape template"""
340
- cx, cy = 540, 750
341
- return {
342
- "title": "Heart",
343
- "paths": [
344
- # Left curve
345
- {"type": "arc", "center": [cx-80, cy-80], "radius": 100, "start_angle": 180, "end_angle": 360, "color": "#E74C3C", "width": 8, "order": 1},
346
- # Right curve
347
- {"type": "arc", "center": [cx+80, cy-80], "radius": 100, "start_angle": 180, "end_angle": 360, "color": "#E74C3C", "width": 8, "order": 2},
348
- # Left line
349
- {"type": "line", "start": [cx-180, cy-80], "end": [cx, cy+150], "color": "#E74C3C", "width": 8, "order": 3},
350
- # Right line
351
- {"type": "line", "start": [cx+180, cy-80], "end": [cx, cy+150], "color": "#E74C3C", "width": 8, "order": 4},
352
- ]
353
- }
354
-
355
- def _sun_template(self) -> Dict:
356
- """Sun with rays"""
357
- cx, cy = 540, 600
358
- rays = []
359
- for i in range(12):
360
- angle = i * math.pi / 6
361
- x1 = cx + 120 * math.cos(angle)
362
- y1 = cy + 120 * math.sin(angle)
363
- x2 = cx + 200 * math.cos(angle)
364
- y2 = cy + 200 * math.sin(angle)
365
- rays.append({
366
- "type": "line",
367
- "start": [int(x1), int(y1)],
368
- "end": [int(x2), int(y2)],
369
- "color": "#F39C12",
370
- "width": 6,
371
- "order": i + 2
372
- })
373
-
374
- return {
375
- "title": "Sun",
376
- "paths": [
377
- {"type": "circle", "center": [cx, cy], "radius": 100, "color": "#F1C40F", "fill": True, "order": 1},
378
- *rays,
379
- ]
380
- }
381
-
382
- def _abstract_template(self, prompt: str) -> Dict:
383
- """Generate abstract pattern based on prompt"""
384
- cx, cy = 540, 800
385
- return {
386
- "title": prompt[:30],
387
- "paths": [
388
- {"type": "circle", "center": [cx, cy], "radius": 150, "color": "#3498DB", "fill": True, "order": 1},
389
- {"type": "circle", "center": [cx, cy], "radius": 100, "color": "#2ECC71", "fill": True, "order": 2},
390
- {"type": "circle", "center": [cx, cy], "radius": 50, "color": "#E74C3C", "fill": True, "order": 3},
391
- {"type": "line", "start": [cx-200, cy], "end": [cx+200, cy], "color": "#2C3E50", "width": 4, "order": 4},
392
- {"type": "line", "start": [cx, cy-200], "end": [cx, cy+200], "color": "#2C3E50", "width": 4, "order": 5},
393
- ]
394
- }
395
-
396
- def hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]:
397
- """Convert hex to RGB"""
398
- hex_color = hex_color.lstrip('#')
399
- return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
400
-
401
- def draw_path(self, draw: ImageDraw, path: Dict, progress: float = 1.0):
402
- """Draw a single path element"""
403
- path_type = path.get("type", "line")
404
- color = self.hex_to_rgb(path.get("color", "#000000"))
405
- width = path.get("width", 3)
406
- fill = path.get("fill", False)
407
-
408
- if path_type == "line":
409
- start = path["start"]
410
- end = path["end"]
411
- # Partial line based on progress
412
- actual_end = [
413
- int(start[0] + (end[0] - start[0]) * progress),
414
- int(start[1] + (end[1] - start[1]) * progress)
415
- ]
416
- draw.line([tuple(start), tuple(actual_end)], fill=color, width=width)
417
-
418
- elif path_type == "circle":
419
- center = path["center"]
420
- radius = int(path["radius"] * progress)
421
- bbox = [center[0]-radius, center[1]-radius, center[0]+radius, center[1]+radius]
422
- if fill:
423
- draw.ellipse(bbox, fill=color)
424
- else:
425
- draw.ellipse(bbox, outline=color, width=width)
426
-
427
- elif path_type == "polygon":
428
- points = path["points"]
429
- # Draw polygon with progress controlling how many points
430
- num_points = max(3, int(len(points) * progress))
431
- visible_points = [tuple(p) for p in points[:num_points]]
432
- if len(visible_points) >= 3:
433
- if fill:
434
- draw.polygon(visible_points, fill=color)
435
- else:
436
- draw.polygon(visible_points, outline=color, width=width)
437
-
438
- elif path_type == "arc":
439
- center = path["center"]
440
- radius = path["radius"]
441
- start_angle = path.get("start_angle", 0)
442
- end_angle = path.get("end_angle", 360)
443
- # Progress affects the arc sweep
444
- actual_end = start_angle + (end_angle - start_angle) * progress
445
- bbox = [center[0]-radius, center[1]-radius, center[0]+radius, center[1]+radius]
446
- draw.arc(bbox, start_angle, actual_end, fill=color, width=width)
447
-
448
- def create_frame(self, paths: List[Dict], path_progress: Dict[int, float]) -> Image.Image:
449
- """Create a single frame with given path progress"""
450
- img = Image.new('RGB', (self.WIDTH, self.HEIGHT), self.BG_COLOR)
451
- draw = ImageDraw.Draw(img)
452
-
453
- # Sort paths by order
454
- sorted_paths = sorted(paths, key=lambda p: p.get("order", 0))
455
-
456
- for path in sorted_paths:
457
- order = path.get("order", 0)
458
- progress = path_progress.get(order, 0.0)
459
- if progress > 0:
460
- self.draw_path(draw, path, progress)
461
-
462
- return img
463
-
464
- def generate_animation(
465
- self,
466
- prompt: str,
467
- output_dir: str = "temp",
468
- frames_per_path: int = 10,
469
- fps: int = 30
470
- ) -> List[str]:
471
- """
472
- Generate frame-by-frame drawing animation from prompt.
473
-
474
- Args:
475
- prompt: What to draw
476
- output_dir: Directory to save frames
477
- frames_per_path: Frames for each path element
478
- fps: Target frame rate
479
-
480
- Returns:
481
- List of frame file paths
482
- """
483
- os.makedirs(output_dir, exist_ok=True)
484
-
485
- # Generate paths with AI
486
- logger.info(f"Generating drawing paths for: {prompt}")
487
- drawing = self.generate_paths_with_ai(prompt)
488
-
489
- paths = drawing.get("paths", [])
490
- title = drawing.get("title", prompt)
491
-
492
- logger.info(f"Drawing '{title}' with {len(paths)} paths")
493
-
494
- # Sort paths by order
495
- sorted_paths = sorted(paths, key=lambda p: p.get("order", 0))
496
-
497
- frame_paths = []
498
- frame_num = 0
499
-
500
- # Animate each path
501
- for path_idx, path in enumerate(sorted_paths):
502
- order = path.get("order", path_idx)
503
-
504
- # Previous paths are complete
505
- base_progress = {p.get("order", i): 1.0 for i, p in enumerate(sorted_paths[:path_idx])}
506
-
507
- # Animate current path
508
- for f in range(frames_per_path):
509
- progress = (f + 1) / frames_per_path
510
- base_progress[order] = progress
511
-
512
- frame = self.create_frame(paths, base_progress.copy())
513
-
514
- # Add title
515
- draw = ImageDraw.Draw(frame)
516
- bbox = draw.textbbox((0, 0), title, font=self.font_title)
517
- title_width = bbox[2] - bbox[0]
518
- draw.text(((self.WIDTH - title_width) // 2, 100), title,
519
- fill=(50, 50, 50), font=self.font_title)
520
-
521
- # Save frame
522
- frame_path = os.path.join(output_dir, f"frame_{frame_num:05d}.png")
523
- frame.save(frame_path)
524
- frame_paths.append(frame_path)
525
- frame_num += 1
526
-
527
- # Hold final frame
528
- for _ in range(fps): # 1 second hold
529
- frame_path = os.path.join(output_dir, f"frame_{frame_num:05d}.png")
530
- frame.save(frame_path)
531
- frame_paths.append(frame_path)
532
- frame_num += 1
533
-
534
- logger.info(f"Generated {len(frame_paths)} animation frames")
535
- return frame_paths
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modules/art_reels/services/ai_stick_figure.py DELETED
@@ -1,663 +0,0 @@
1
- """
2
- AI Stick Figure Generator - AI-Powered Stick Figure Animation with TTS
3
- Uses Groq AI for scene generation, Kokoro TTS for voice, Whisper for timing
4
- """
5
- import logging
6
- import os
7
- import json
8
- import math
9
- from PIL import Image, ImageDraw, ImageFont
10
- from typing import List, Tuple, Dict, Optional
11
- from groq import Groq
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
-
16
- # System prompt for AI scene generation
17
- SCENE_GENERATION_PROMPT = """You are an AI that converts text into stick figure animation scenes.
18
-
19
- For each 2-second chunk of narration, generate a scene description.
20
-
21
- OUTPUT FORMAT (JSON array):
22
- [
23
- {
24
- "chunk_id": 0,
25
- "pose": "standing|walking|running|sitting|sleeping|waving|thinking|jumping|celebrating",
26
- "props": ["crown", "money", "book", "phone", "laptop", "coffee", "bed"],
27
- "text_overlay": null or "important quote here",
28
- "emotion": "happy|sad|thinking|excited|tired|angry",
29
- "action": "brief action description"
30
- }
31
- ]
32
-
33
- RULES:
34
- 1. Match the pose and props to the MEANING of the text
35
- 2. Use text_overlay ONLY for important quotes or key phrases
36
- 3. Be creative with emotions and actions
37
- 4. Keep it simple - stick figures should be expressive but minimal
38
-
39
- EXAMPLES:
40
- - "A rich businessman" → pose: "standing", props: ["money", "suit"], emotion: "happy"
41
- - "He woke up from sleep" → pose: "sleeping", props: ["bed"], emotion: "tired"
42
- - "Get ready for success" → pose: "celebrating", props: [], text_overlay: "GET READY!"
43
- - "Walk like a king" → pose: "walking", props: ["crown"], emotion: "happy"
44
- """
45
-
46
-
47
- class AIStickFigure:
48
- """
49
- AI-Powered Stick Figure Animation Generator.
50
-
51
- Pipeline:
52
- 1. Text → TTS → Audio
53
- 2. Audio → Whisper → 2-second chunks
54
- 3. Chunks → Groq AI → Scene descriptions
55
- 4. Scenes → Python Drawing → Frames
56
- 5. Frames + Audio → Video
57
- """
58
-
59
- # Video dimensions (9:16 portrait)
60
- WIDTH = 1080
61
- HEIGHT = 1920
62
-
63
- # Stick figure size
64
- HEAD_RADIUS = 50
65
- BODY_LENGTH = 150
66
- ARM_LENGTH = 100
67
- LEG_LENGTH = 120
68
- LINE_WIDTH = 10
69
-
70
- # Colors
71
- BG_COLOR = (255, 255, 255) # White
72
- FIGURE_COLOR = (30, 30, 30) # Near black
73
- ACCENT_COLOR = (255, 87, 51) # Orange accent
74
-
75
- def __init__(self, groq_api_key: str = None):
76
- self.groq_api_key = groq_api_key or os.environ.get("GROQ_API_KEY")
77
- if self.groq_api_key:
78
- self.groq = Groq(api_key=self.groq_api_key)
79
- else:
80
- self.groq = None
81
- logger.warning("Groq API key not found - AI scene generation disabled")
82
-
83
- def generate_scenes_with_ai(self, chunks: List[Dict]) -> List[Dict]:
84
- """
85
- Use Groq AI to generate scene descriptions from text chunks.
86
-
87
- Args:
88
- chunks: List of {chunk_id, text} from Whisper
89
-
90
- Returns:
91
- List of scene descriptions with pose, props, etc.
92
- """
93
- if not self.groq:
94
- # Fallback to keyword-based generation
95
- return self._generate_scenes_keyword(chunks)
96
-
97
- try:
98
- # Prepare user prompt
99
- chunk_texts = "\n".join([
100
- f"Chunk {c['chunk_id']}: \"{c['text']}\""
101
- for c in chunks
102
- ])
103
-
104
- user_prompt = f"""Generate stick figure scenes for these narration chunks:
105
-
106
- {chunk_texts}
107
-
108
- Generate exactly {len(chunks)} scenes, one for each chunk.
109
- Return ONLY valid JSON array, no other text."""
110
-
111
- response = self.groq.chat.completions.create(
112
- model="openai/gpt-oss-120b",
113
- messages=[
114
- {"role": "system", "content": SCENE_GENERATION_PROMPT},
115
- {"role": "user", "content": user_prompt}
116
- ],
117
- temperature=0.7,
118
- max_tokens=2000
119
- )
120
-
121
- content = response.choices[0].message.content.strip()
122
-
123
- # Parse JSON
124
- if content.startswith("```"):
125
- content = content.split("```")[1]
126
- if content.startswith("json"):
127
- content = content[4:]
128
-
129
- scenes = json.loads(content)
130
- logger.info(f"AI generated {len(scenes)} scenes")
131
- return scenes
132
-
133
- except Exception as e:
134
- logger.error(f"AI scene generation failed: {e}, using keyword fallback")
135
- return self._generate_scenes_keyword(chunks)
136
-
137
- def _generate_scenes_keyword(self, chunks: List[Dict]) -> List[Dict]:
138
- """Fallback keyword-based scene generation"""
139
- scenes = []
140
-
141
- keyword_mapping = {
142
- # Poses
143
- "sleep": ("sleeping", [], "tired"),
144
- "wake": ("sleeping", [], "tired"),
145
- "run": ("running", [], "excited"),
146
- "running": ("running", [], "excited"),
147
- "walk": ("walking", [], "happy"),
148
- "walking": ("walking", [], "happy"),
149
- "sit": ("sitting", [], "thinking"),
150
- "sitting": ("sitting", [], "thinking"),
151
- "think": ("thinking", [], "thinking"),
152
- "thinking": ("thinking", [], "thinking"),
153
- "jump": ("jumping", [], "excited"),
154
- "celebrate": ("celebrating", [], "happy"),
155
- "success": ("celebrating", [], "excited"),
156
- # Props
157
- "king": ("standing", ["crown"], "happy"),
158
- "queen": ("standing", ["crown"], "happy"),
159
- "rich": ("standing", ["money"], "happy"),
160
- "wealthy": ("standing", ["money"], "happy"),
161
- "money": ("standing", ["money"], "happy"),
162
- "dollar": ("standing", ["money"], "happy"),
163
- "book": ("sitting", ["book"], "thinking"),
164
- "read": ("sitting", ["book"], "thinking"),
165
- "phone": ("standing", ["phone"], "happy"),
166
- "coffee": ("standing", ["coffee"], "happy"),
167
- }
168
-
169
- for chunk in chunks:
170
- text = chunk.get("text", "").lower()
171
- pose = "standing"
172
- props = []
173
- emotion = "happy"
174
- text_overlay = None
175
-
176
- for keyword, (p, pr, em) in keyword_mapping.items():
177
- if keyword in text:
178
- pose = p
179
- props = pr
180
- emotion = em
181
- break
182
-
183
- # Check for text overlay triggers
184
- triggers = ["get ready", "remember", "important", "key", "ready", "success"]
185
- for trigger in triggers:
186
- if trigger in text:
187
- text_overlay = text[:30].upper() if len(text) > 30 else text.upper()
188
- break
189
-
190
- scenes.append({
191
- "chunk_id": chunk.get("chunk_id", 0),
192
- "pose": pose,
193
- "props": props,
194
- "emotion": emotion,
195
- "text_overlay": text_overlay,
196
- "action": f"Scene for: {text[:50]}"
197
- })
198
-
199
- return scenes
200
-
201
- def draw_stick_figure(
202
- self,
203
- draw: ImageDraw,
204
- x: int,
205
- y: int,
206
- pose: str = "standing",
207
- emotion: str = "happy",
208
- props: List[str] = None,
209
- scale: float = 1.0
210
- ):
211
- """Draw an expressive stick figure"""
212
- props = props or []
213
-
214
- # Scale dimensions
215
- head_r = int(self.HEAD_RADIUS * scale)
216
- body_len = int(self.BODY_LENGTH * scale)
217
- arm_len = int(self.ARM_LENGTH * scale)
218
- leg_len = int(self.LEG_LENGTH * scale)
219
- line_w = max(4, int(self.LINE_WIDTH * scale))
220
-
221
- # Pose-specific drawing
222
- pose_methods = {
223
- "standing": self._draw_standing,
224
- "walking": self._draw_walking,
225
- "running": self._draw_running,
226
- "sitting": self._draw_sitting,
227
- "sleeping": self._draw_sleeping,
228
- "waving": self._draw_waving,
229
- "thinking": self._draw_thinking,
230
- "jumping": self._draw_jumping,
231
- "celebrating": self._draw_celebrating,
232
- }
233
-
234
- draw_method = pose_methods.get(pose, self._draw_standing)
235
- draw_method(draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion)
236
-
237
- # Draw props
238
- for prop in props:
239
- self._draw_prop(draw, x, y - body_len - head_r, head_r, prop, scale)
240
-
241
- def _draw_face(self, draw, x, y, head_r, emotion, line_w):
242
- """Draw expressive face"""
243
- # Eyes
244
- eye_y = y - head_r // 4
245
- eye_offset = head_r // 3
246
- eye_size = head_r // 5
247
-
248
- if emotion == "happy":
249
- # Happy eyes (curved)
250
- draw.arc([x - eye_offset - eye_size, eye_y - eye_size,
251
- x - eye_offset + eye_size, eye_y + eye_size],
252
- 0, 180, fill=self.FIGURE_COLOR, width=line_w//2)
253
- draw.arc([x + eye_offset - eye_size, eye_y - eye_size,
254
- x + eye_offset + eye_size, eye_y + eye_size],
255
- 0, 180, fill=self.FIGURE_COLOR, width=line_w//2)
256
- # Smile
257
- draw.arc([x - head_r//2, y - head_r//4, x + head_r//2, y + head_r//2],
258
- 0, 180, fill=self.FIGURE_COLOR, width=line_w//2)
259
- elif emotion == "sad":
260
- # Sad eyes
261
- draw.ellipse([x - eye_offset - eye_size, eye_y - eye_size,
262
- x - eye_offset + eye_size, eye_y + eye_size],
263
- fill=self.FIGURE_COLOR)
264
- draw.ellipse([x + eye_offset - eye_size, eye_y - eye_size,
265
- x + eye_offset + eye_size, eye_y + eye_size],
266
- fill=self.FIGURE_COLOR)
267
- # Frown
268
- draw.arc([x - head_r//2, y, x + head_r//2, y + head_r//2],
269
- 180, 360, fill=self.FIGURE_COLOR, width=line_w//2)
270
- elif emotion == "thinking":
271
- # Thinking eyes (looking up)
272
- draw.ellipse([x - eye_offset - eye_size, eye_y - eye_size - 5,
273
- x - eye_offset + eye_size, eye_y + eye_size - 5],
274
- fill=self.FIGURE_COLOR)
275
- draw.ellipse([x + eye_offset - eye_size, eye_y - eye_size - 5,
276
- x + eye_offset + eye_size, eye_y + eye_size - 5],
277
- fill=self.FIGURE_COLOR)
278
- # Neutral mouth
279
- draw.line([x - head_r//3, y + head_r//4, x + head_r//3, y + head_r//4],
280
- fill=self.FIGURE_COLOR, width=line_w//2)
281
- elif emotion == "excited":
282
- # Big excited eyes
283
- big_eye = eye_size * 2
284
- draw.ellipse([x - eye_offset - big_eye, eye_y - big_eye,
285
- x - eye_offset + big_eye, eye_y + big_eye],
286
- fill=self.FIGURE_COLOR)
287
- draw.ellipse([x + eye_offset - big_eye, eye_y - big_eye,
288
- x + eye_offset + big_eye, eye_y + big_eye],
289
- fill=self.FIGURE_COLOR)
290
- # Big smile
291
- draw.arc([x - head_r//2, y - head_r//3, x + head_r//2, y + head_r//2],
292
- 0, 180, fill=self.FIGURE_COLOR, width=line_w)
293
- else:
294
- # Default neutral
295
- draw.ellipse([x - eye_offset - eye_size, eye_y - eye_size,
296
- x - eye_offset + eye_size, eye_y + eye_size],
297
- fill=self.FIGURE_COLOR)
298
- draw.ellipse([x + eye_offset - eye_size, eye_y - eye_size,
299
- x + eye_offset + eye_size, eye_y + eye_size],
300
- fill=self.FIGURE_COLOR)
301
- draw.line([x - head_r//3, y + head_r//4, x + head_r//3, y + head_r//4],
302
- fill=self.FIGURE_COLOR, width=line_w//2)
303
-
304
- def _draw_standing(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
305
- """Draw standing pose with expression"""
306
- head_y = y - body_len - head_r
307
-
308
- # Head circle
309
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
310
- outline=self.FIGURE_COLOR, width=line_w)
311
- self._draw_face(draw, x, head_y, head_r, emotion, line_w)
312
-
313
- # Body
314
- body_top = head_y + head_r
315
- body_bottom = body_top + body_len
316
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
317
-
318
- # Arms
319
- arm_y = body_top + body_len // 4
320
- draw.line([x, arm_y, x - arm_len, arm_y + arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
321
- draw.line([x, arm_y, x + arm_len, arm_y + arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
322
-
323
- # Legs
324
- draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
325
- draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
326
-
327
- def _draw_walking(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
328
- """Draw walking pose"""
329
- head_y = y - body_len - head_r
330
-
331
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
332
- outline=self.FIGURE_COLOR, width=line_w)
333
- self._draw_face(draw, x, head_y, head_r, emotion, line_w)
334
-
335
- body_top = head_y + head_r
336
- body_bottom = body_top + body_len
337
- draw.line([x, body_top, x + 10, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
338
-
339
- arm_y = body_top + body_len // 4
340
- draw.line([x, arm_y, x - arm_len//2, arm_y + arm_len], fill=self.FIGURE_COLOR, width=line_w)
341
- draw.line([x, arm_y, x + arm_len, arm_y - arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
342
-
343
- draw.line([x + 10, body_bottom, x - leg_len, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
344
- draw.line([x + 10, body_bottom, x + leg_len, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
345
-
346
- def _draw_running(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
347
- """Draw running pose"""
348
- head_y = y - body_len - head_r
349
-
350
- draw.ellipse([x + 20 - head_r, head_y - head_r, x + 20 + head_r, head_y + head_r],
351
- outline=self.FIGURE_COLOR, width=line_w)
352
- self._draw_face(draw, x + 20, head_y, head_r, "excited", line_w)
353
-
354
- body_top = head_y + head_r
355
- body_bottom = body_top + body_len
356
- draw.line([x + 20, body_top, x + 40, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
357
-
358
- arm_y = body_top + body_len // 4 + 20
359
- draw.line([x + 20, arm_y, x - arm_len, arm_y + arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
360
- draw.line([x + 20, arm_y, x + arm_len + 30, arm_y - arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
361
-
362
- draw.line([x + 40, body_bottom, x - leg_len - 20, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
363
- draw.line([x + 40, body_bottom, x + leg_len + 40, body_bottom + leg_len//3], fill=self.FIGURE_COLOR, width=line_w)
364
-
365
- def _draw_sitting(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
366
- """Draw sitting pose"""
367
- y_offset = leg_len // 2
368
- head_y = y - body_len - head_r + y_offset
369
-
370
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
371
- outline=self.FIGURE_COLOR, width=line_w)
372
- self._draw_face(draw, x, head_y, head_r, emotion, line_w)
373
-
374
- body_top = head_y + head_r
375
- body_bottom = body_top + body_len // 2
376
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
377
-
378
- arm_y = body_top + body_len // 6
379
- draw.line([x, arm_y, x - arm_len // 2, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
380
- draw.line([x, arm_y, x + arm_len // 2, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
381
-
382
- # Chair/ground
383
- draw.line([x - leg_len, body_bottom, x + leg_len, body_bottom], fill=self.FIGURE_COLOR, width=line_w//2)
384
- draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
385
- draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
386
-
387
- def _draw_sleeping(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
388
- """Draw horizontal sleeping pose"""
389
- # Horizontal figure
390
- draw.ellipse([x - body_len - head_r, y - head_r, x - body_len + head_r, y + head_r],
391
- outline=self.FIGURE_COLOR, width=line_w)
392
-
393
- # Closed eyes (lines)
394
- draw.line([x - body_len - head_r//2, y - head_r//4, x - body_len - head_r//4, y - head_r//4],
395
- fill=self.FIGURE_COLOR, width=line_w//2)
396
- draw.line([x - body_len + head_r//4, y - head_r//4, x - body_len + head_r//2, y - head_r//4],
397
- fill=self.FIGURE_COLOR, width=line_w//2)
398
-
399
- # Body
400
- draw.line([x - body_len + head_r, y, x, y], fill=self.FIGURE_COLOR, width=line_w)
401
-
402
- # Legs
403
- draw.line([x, y, x + leg_len, y + 10], fill=self.FIGURE_COLOR, width=line_w)
404
- draw.line([x, y, x + leg_len, y - 10], fill=self.FIGURE_COLOR, width=line_w)
405
-
406
- # Zzz
407
- try:
408
- font = ImageFont.truetype("arial.ttf", 40)
409
- except:
410
- font = ImageFont.load_default()
411
- draw.text((x - body_len, y - head_r - 50), "Zzz", fill=(100, 100, 100), font=font)
412
-
413
- # Bed
414
- draw.rectangle([x - body_len - head_r - 20, y + head_r, x + leg_len + 20, y + head_r + 20],
415
- fill=(139, 90, 43))
416
-
417
- def _draw_waving(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
418
- """Draw waving pose"""
419
- head_y = y - body_len - head_r
420
-
421
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
422
- outline=self.FIGURE_COLOR, width=line_w)
423
- self._draw_face(draw, x, head_y, head_r, "happy", line_w)
424
-
425
- body_top = head_y + head_r
426
- body_bottom = body_top + body_len
427
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
428
-
429
- arm_y = body_top + body_len // 4
430
- draw.line([x, arm_y, x - arm_len, arm_y + arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
431
- # Waving arm up
432
- draw.line([x, arm_y, x + arm_len//2, arm_y - arm_len], fill=self.FIGURE_COLOR, width=line_w)
433
- # Hand wave lines
434
- draw.line([x + arm_len//2 - 10, arm_y - arm_len - 30, x + arm_len//2 + 10, arm_y - arm_len - 20],
435
- fill=self.ACCENT_COLOR, width=3)
436
- draw.line([x + arm_len//2 + 15, arm_y - arm_len - 25, x + arm_len//2 + 35, arm_y - arm_len - 15],
437
- fill=self.ACCENT_COLOR, width=3)
438
-
439
- draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
440
- draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
441
-
442
- def _draw_thinking(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
443
- """Draw thinking pose with thought bubble"""
444
- head_y = y - body_len - head_r
445
-
446
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
447
- outline=self.FIGURE_COLOR, width=line_w)
448
- self._draw_face(draw, x, head_y, head_r, "thinking", line_w)
449
-
450
- body_top = head_y + head_r
451
- body_bottom = body_top + body_len
452
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
453
-
454
- arm_y = body_top + body_len // 4
455
- draw.line([x, arm_y, x - arm_len, arm_y + arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
456
- # Hand on chin
457
- draw.line([x, arm_y, x + arm_len//3, arm_y - arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
458
- draw.line([x + arm_len//3, arm_y - arm_len//3, x + head_r//2, head_y + head_r],
459
- fill=self.FIGURE_COLOR, width=line_w)
460
-
461
- draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
462
- draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
463
-
464
- # Thought bubble
465
- draw.ellipse([x + head_r + 30, head_y - head_r - 100, x + head_r + 150, head_y - head_r - 20],
466
- outline=self.FIGURE_COLOR, width=3)
467
- draw.ellipse([x + head_r + 10, head_y - head_r - 20, x + head_r + 30, head_y - head_r],
468
- fill=self.FIGURE_COLOR)
469
-
470
- def _draw_jumping(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
471
- """Draw jumping pose"""
472
- y_offset = -80 # Jump up
473
- head_y = y - body_len - head_r + y_offset
474
-
475
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
476
- outline=self.FIGURE_COLOR, width=line_w)
477
- self._draw_face(draw, x, head_y, head_r, "excited", line_w)
478
-
479
- body_top = head_y + head_r
480
- body_bottom = body_top + body_len
481
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
482
-
483
- # Arms up
484
- arm_y = body_top + body_len // 4
485
- draw.line([x, arm_y, x - arm_len, arm_y - arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
486
- draw.line([x, arm_y, x + arm_len, arm_y - arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
487
-
488
- # Legs spread
489
- draw.line([x, body_bottom, x - leg_len, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
490
- draw.line([x, body_bottom, x + leg_len, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
491
-
492
- # Jump lines
493
- draw.line([x - 30, body_bottom + leg_len + 30, x + 30, body_bottom + leg_len + 30],
494
- fill=(200, 200, 200), width=4)
495
-
496
- def _draw_celebrating(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
497
- """Draw celebrating pose with confetti"""
498
- head_y = y - body_len - head_r
499
-
500
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
501
- outline=self.FIGURE_COLOR, width=line_w)
502
- self._draw_face(draw, x, head_y, head_r, "excited", line_w)
503
-
504
- body_top = head_y + head_r
505
- body_bottom = body_top + body_len
506
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
507
-
508
- # Both arms up
509
- arm_y = body_top + body_len // 4
510
- draw.line([x, arm_y, x - arm_len, arm_y - arm_len], fill=self.FIGURE_COLOR, width=line_w)
511
- draw.line([x, arm_y, x + arm_len, arm_y - arm_len], fill=self.FIGURE_COLOR, width=line_w)
512
-
513
- draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
514
- draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
515
-
516
- # Confetti
517
- import random
518
- colors = [(255, 87, 51), (255, 195, 0), (76, 175, 80), (33, 150, 243)]
519
- for _ in range(10):
520
- cx = x + random.randint(-150, 150)
521
- cy = head_y + random.randint(-150, 50)
522
- color = random.choice(colors)
523
- draw.ellipse([cx - 5, cy - 5, cx + 5, cy + 5], fill=color)
524
-
525
- def _draw_prop(self, draw, x, head_y, head_r, prop: str, scale: float):
526
- """Draw props on the figure"""
527
- if prop == "crown":
528
- crown_y = head_y - head_r - 20
529
- points = [
530
- (x - 40, crown_y),
531
- (x - 25, crown_y - 40),
532
- (x, crown_y - 20),
533
- (x + 25, crown_y - 40),
534
- (x + 40, crown_y)
535
- ]
536
- draw.polygon(points, fill=(255, 215, 0), outline=(200, 170, 0), width=2)
537
- elif prop == "money":
538
- bag_x = x + 100
539
- bag_y = head_y + 150
540
- draw.ellipse([bag_x, bag_y, bag_x + 60, bag_y + 70], fill=(34, 139, 34), outline=(20, 100, 20), width=2)
541
- try:
542
- font = ImageFont.truetype("arial.ttf", 30)
543
- except:
544
- font = ImageFont.load_default()
545
- draw.text((bag_x + 20, bag_y + 20), "$", fill=(255, 255, 255), font=font)
546
- elif prop == "book":
547
- book_x = x + 80
548
- book_y = head_y + 130
549
- draw.rectangle([book_x, book_y, book_x + 50, book_y + 70], fill=(139, 69, 19), outline=(100, 50, 10), width=2)
550
- draw.line([book_x + 25, book_y, book_x + 25, book_y + 70], fill=(80, 40, 10), width=2)
551
- elif prop == "phone":
552
- phone_x = x + 90
553
- phone_y = head_y + 100
554
- draw.rectangle([phone_x, phone_y, phone_x + 30, phone_y + 50], fill=(50, 50, 50), outline=(30, 30, 30), width=2)
555
- draw.rectangle([phone_x + 3, phone_y + 5, phone_x + 27, phone_y + 40], fill=(100, 150, 200))
556
- elif prop == "coffee":
557
- cup_x = x + 100
558
- cup_y = head_y + 120
559
- draw.rectangle([cup_x, cup_y, cup_x + 40, cup_y + 50], fill=(255, 255, 255), outline=(200, 200, 200), width=2)
560
- draw.arc([cup_x + 30, cup_y + 10, cup_x + 50, cup_y + 40], -90, 90, fill=(200, 200, 200), width=3)
561
- # Steam
562
- draw.arc([cup_x + 10, cup_y - 20, cup_x + 20, cup_y], 0, 180, fill=(200, 200, 200), width=2)
563
- draw.arc([cup_x + 20, cup_y - 25, cup_x + 30, cup_y - 5], 180, 360, fill=(200, 200, 200), width=2)
564
-
565
- def add_text_overlay(
566
- self,
567
- img: Image.Image,
568
- text: str,
569
- position: str = "bottom"
570
- ) -> Image.Image:
571
- """Add stylish text overlay"""
572
- draw = ImageDraw.Draw(img)
573
-
574
- try:
575
- font = ImageFont.truetype("arial.ttf", 70)
576
- except:
577
- font = ImageFont.load_default()
578
-
579
- bbox = draw.textbbox((0, 0), text, font=font)
580
- text_width = bbox[2] - bbox[0]
581
- text_height = bbox[3] - bbox[1]
582
-
583
- if position == "center":
584
- pos = ((self.WIDTH - text_width) // 2, (self.HEIGHT - text_height) // 2)
585
- elif position == "top":
586
- pos = ((self.WIDTH - text_width) // 2, 150)
587
- else: # bottom
588
- pos = ((self.WIDTH - text_width) // 2, self.HEIGHT - 200)
589
-
590
- # Background box
591
- padding = 20
592
- draw.rectangle([
593
- pos[0] - padding, pos[1] - padding,
594
- pos[0] + text_width + padding, pos[1] + text_height + padding
595
- ], fill=(0, 0, 0, 180))
596
-
597
- # Text
598
- draw.text(pos, text, fill=(255, 255, 255), font=font,
599
- stroke_width=2, stroke_fill=(0, 0, 0))
600
-
601
- return img
602
-
603
- def create_scene_frame(self, scene: Dict) -> Image.Image:
604
- """Create a single frame from scene description"""
605
- img = Image.new('RGB', (self.WIDTH, self.HEIGHT), self.BG_COLOR)
606
- draw = ImageDraw.Draw(img)
607
-
608
- # Draw figure
609
- pose = scene.get("pose", "standing")
610
- props = scene.get("props", [])
611
- emotion = scene.get("emotion", "happy")
612
-
613
- self.draw_stick_figure(draw, self.WIDTH // 2, self.HEIGHT // 2 + 150,
614
- pose=pose, emotion=emotion, props=props, scale=1.5)
615
-
616
- # Add text overlay if present
617
- text_overlay = scene.get("text_overlay")
618
- if text_overlay:
619
- self.add_text_overlay(img, text_overlay)
620
-
621
- return img
622
-
623
- def generate_frames_from_scenes(
624
- self,
625
- scenes: List[Dict],
626
- chunk_durations: List[float],
627
- output_dir: str,
628
- fps: int = 30
629
- ) -> List[str]:
630
- """
631
- Generate frames for all scenes with correct timing.
632
-
633
- Args:
634
- scenes: List of scene descriptions
635
- chunk_durations: Duration of each chunk in seconds
636
- output_dir: Directory to save frames
637
- fps: Frames per second
638
-
639
- Returns:
640
- List of frame file paths
641
- """
642
- os.makedirs(output_dir, exist_ok=True)
643
- frame_paths = []
644
- frame_num = 0
645
-
646
- for i, scene in enumerate(scenes):
647
- duration = chunk_durations[i] if i < len(chunk_durations) else 2.0
648
- num_frames = int(duration * fps)
649
-
650
- logger.info(f"Generating {num_frames} frames for scene {i}")
651
-
652
- # Create scene frame
653
- frame = self.create_scene_frame(scene)
654
-
655
- # Save frames for duration
656
- for _ in range(num_frames):
657
- frame_path = os.path.join(output_dir, f"frame_{frame_num:05d}.png")
658
- frame.save(frame_path)
659
- frame_paths.append(frame_path)
660
- frame_num += 1
661
-
662
- logger.info(f"Generated {len(frame_paths)} total frames")
663
- return frame_paths
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modules/art_reels/services/block_art.py DELETED
@@ -1,283 +0,0 @@
1
- """
2
- Block Art - Minecraft-style Isometric Block Drawing
3
- Pure Python implementation using Pillow
4
- """
5
- import logging
6
- import math
7
- from PIL import Image, ImageDraw, ImageFont
8
- from typing import List, Tuple, Dict
9
- import os
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class BlockArt:
15
- """
16
- Creates Minecraft-style isometric block art.
17
-
18
- Features:
19
- - Isometric 3D blocks
20
- - Multiple block types (wood, stone, grass, etc.)
21
- - Frame-by-frame build animation
22
- """
23
-
24
- # Video dimensions (9:16 portrait)
25
- WIDTH = 1080
26
- HEIGHT = 1920
27
-
28
- # Block size and offset for isometric projection
29
- BLOCK_SIZE = 40
30
-
31
- # Block colors (top, left, right faces)
32
- BLOCK_COLORS = {
33
- "oak_wood": {
34
- "top": "#8B7355",
35
- "left": "#6B5344",
36
- "right": "#5A4535"
37
- },
38
- "stone": {
39
- "top": "#8C8C8C",
40
- "left": "#6E6E6E",
41
- "right": "#5A5A5A"
42
- },
43
- "cobblestone": {
44
- "top": "#7A7A7A",
45
- "left": "#5C5C5C",
46
- "right": "#4A4A4A"
47
- },
48
- "grass": {
49
- "top": "#4CAF50",
50
- "left": "#8B7355",
51
- "right": "#6B5344"
52
- },
53
- "dirt": {
54
- "top": "#8B6914",
55
- "left": "#6B5010",
56
- "right": "#5A420D"
57
- },
58
- "glass": {
59
- "top": "#ADD8E6",
60
- "left": "#87CEEB",
61
- "right": "#7EC8E3"
62
- },
63
- "brick": {
64
- "top": "#CB4154",
65
- "left": "#A33545",
66
- "right": "#8B2D3A"
67
- },
68
- "planks": {
69
- "top": "#C4A35A",
70
- "left": "#A08040",
71
- "right": "#8A6B30"
72
- },
73
- "roof": {
74
- "top": "#8B4513",
75
- "left": "#6B3410",
76
- "right": "#5A2B0D"
77
- }
78
- }
79
-
80
- def __init__(self):
81
- self.frames = []
82
-
83
- def hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]:
84
- """Convert hex color to RGB tuple"""
85
- hex_color = hex_color.lstrip('#')
86
- return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
87
-
88
- def iso_to_screen(self, x: int, y: int, z: int) -> Tuple[int, int]:
89
- """Convert 3D isometric coordinates to 2D screen position"""
90
- # Isometric projection
91
- screen_x = self.WIDTH // 2 + (x - y) * (self.BLOCK_SIZE // 2)
92
- screen_y = self.HEIGHT // 2 + (x + y) * (self.BLOCK_SIZE // 4) - z * (self.BLOCK_SIZE // 2)
93
- return (screen_x, screen_y)
94
-
95
- def draw_block(self, draw: ImageDraw, x: int, y: int, z: int, block_type: str = "oak_wood"):
96
- """Draw a single isometric block"""
97
- colors = self.BLOCK_COLORS.get(block_type, self.BLOCK_COLORS["oak_wood"])
98
-
99
- # Calculate screen position
100
- sx, sy = self.iso_to_screen(x, y, z)
101
-
102
- half = self.BLOCK_SIZE // 2
103
- quarter = self.BLOCK_SIZE // 4
104
-
105
- # Top face (diamond shape)
106
- top_points = [
107
- (sx, sy - half), # top
108
- (sx + half, sy - quarter), # right
109
- (sx, sy), # bottom
110
- (sx - half, sy - quarter) # left
111
- ]
112
- draw.polygon(top_points, fill=self.hex_to_rgb(colors["top"]), outline=(0, 0, 0))
113
-
114
- # Left face
115
- left_points = [
116
- (sx - half, sy - quarter), # top
117
- (sx, sy), # right
118
- (sx, sy + half), # bottom
119
- (sx - half, sy + quarter) # left
120
- ]
121
- draw.polygon(left_points, fill=self.hex_to_rgb(colors["left"]), outline=(0, 0, 0))
122
-
123
- # Right face
124
- right_points = [
125
- (sx, sy), # left
126
- (sx + half, sy - quarter), # top
127
- (sx + half, sy + quarter), # right
128
- (sx, sy + half) # bottom
129
- ]
130
- draw.polygon(right_points, fill=self.hex_to_rgb(colors["right"]), outline=(0, 0, 0))
131
-
132
- def generate_simple_house(self) -> List[Dict]:
133
- """Generate block positions for a simple house"""
134
- blocks = []
135
-
136
- # Ground/Floor (5x5)
137
- for x in range(5):
138
- for y in range(5):
139
- blocks.append({"x": x, "y": y, "z": 0, "type": "grass"})
140
-
141
- # Floor (3x3 planks)
142
- for x in range(1, 4):
143
- for y in range(1, 4):
144
- blocks.append({"x": x, "y": y, "z": 1, "type": "planks"})
145
-
146
- # Walls - Layer 1
147
- for x in range(1, 4):
148
- blocks.append({"x": x, "y": 0, "z": 2, "type": "oak_wood"})
149
- blocks.append({"x": x, "y": 4, "z": 2, "type": "oak_wood"})
150
- for y in range(1, 4):
151
- blocks.append({"x": 0, "y": y, "z": 2, "type": "oak_wood"})
152
- blocks.append({"x": 4, "y": y, "z": 2, "type": "oak_wood"})
153
-
154
- # Walls - Layer 2
155
- for x in range(1, 4):
156
- blocks.append({"x": x, "y": 0, "z": 3, "type": "oak_wood"})
157
- blocks.append({"x": x, "y": 4, "z": 3, "type": "oak_wood"})
158
- for y in range(1, 4):
159
- blocks.append({"x": 0, "y": y, "z": 3, "type": "oak_wood"})
160
- blocks.append({"x": 4, "y": y, "z": 3, "type": "oak_wood"})
161
-
162
- # Corner posts
163
- for z in [2, 3, 4]:
164
- blocks.append({"x": 0, "y": 0, "z": z, "type": "oak_wood"})
165
- blocks.append({"x": 4, "y": 0, "z": z, "type": "oak_wood"})
166
- blocks.append({"x": 0, "y": 4, "z": z, "type": "oak_wood"})
167
- blocks.append({"x": 4, "y": 4, "z": z, "type": "oak_wood"})
168
-
169
- # Window (glass)
170
- blocks.append({"x": 2, "y": 0, "z": 3, "type": "glass"})
171
- blocks.append({"x": 2, "y": 4, "z": 3, "type": "glass"})
172
-
173
- # Roof
174
- for x in range(5):
175
- for y in range(5):
176
- blocks.append({"x": x, "y": y, "z": 5, "type": "roof"})
177
-
178
- return blocks
179
-
180
- def create_frame(self, blocks_to_draw: List[Dict], bg_color: str = "#87CEEB") -> Image.Image:
181
- """Create a single frame with given blocks"""
182
- # Create image with sky blue background
183
- img = Image.new('RGB', (self.WIDTH, self.HEIGHT), self.hex_to_rgb(bg_color))
184
- draw = ImageDraw.Draw(img)
185
-
186
- # Sort blocks for proper rendering (back to front, bottom to top)
187
- sorted_blocks = sorted(blocks_to_draw, key=lambda b: (b["z"], b["x"] + b["y"]))
188
-
189
- for block in sorted_blocks:
190
- self.draw_block(draw, block["x"], block["y"], block["z"], block["type"])
191
-
192
- return img
193
-
194
- def generate_build_animation(
195
- self,
196
- description: str = "wooden survival house",
197
- output_dir: str = "temp",
198
- blocks_per_frame: int = 2
199
- ) -> List[str]:
200
- """
201
- Generate frame-by-frame build animation.
202
-
203
- Args:
204
- description: What to build (for future AI integration)
205
- output_dir: Directory to save frames
206
- blocks_per_frame: How many blocks to add per frame
207
-
208
- Returns:
209
- List of frame file paths
210
- """
211
- os.makedirs(output_dir, exist_ok=True)
212
-
213
- # Get house blocks
214
- all_blocks = self.generate_simple_house()
215
-
216
- logger.info(f"Generating animation with {len(all_blocks)} blocks")
217
-
218
- frame_paths = []
219
- frame_num = 0
220
-
221
- # Generate frames progressively
222
- for i in range(0, len(all_blocks), blocks_per_frame):
223
- current_blocks = all_blocks[:i + blocks_per_frame]
224
-
225
- # Create frame
226
- frame = self.create_frame(current_blocks)
227
-
228
- # Add "Building..." text
229
- draw = ImageDraw.Draw(frame)
230
- try:
231
- font = ImageFont.truetype("arial.ttf", 48)
232
- except:
233
- font = ImageFont.load_default()
234
-
235
- progress = min(100, int((i + blocks_per_frame) / len(all_blocks) * 100))
236
- text = f"Building... {progress}%"
237
-
238
- # Center text at bottom
239
- bbox = draw.textbbox((0, 0), text, font=font)
240
- text_width = bbox[2] - bbox[0]
241
- draw.text(
242
- ((self.WIDTH - text_width) // 2, self.HEIGHT - 150),
243
- text,
244
- fill=(255, 255, 255),
245
- font=font,
246
- stroke_width=2,
247
- stroke_fill=(0, 0, 0)
248
- )
249
-
250
- # Save frame
251
- frame_path = os.path.join(output_dir, f"frame_{frame_num:04d}.png")
252
- frame.save(frame_path)
253
- frame_paths.append(frame_path)
254
- frame_num += 1
255
-
256
- # Hold final frame for a bit
257
- final_frame = self.create_frame(all_blocks)
258
- draw = ImageDraw.Draw(final_frame)
259
- try:
260
- font = ImageFont.truetype("arial.ttf", 60)
261
- except:
262
- font = ImageFont.load_default()
263
-
264
- text = "Done! Complete!"
265
- bbox = draw.textbbox((0, 0), text, font=font)
266
- text_width = bbox[2] - bbox[0]
267
- draw.text(
268
- ((self.WIDTH - text_width) // 2, self.HEIGHT - 150),
269
- text,
270
- fill=(76, 175, 80),
271
- font=font,
272
- stroke_width=2,
273
- stroke_fill=(0, 0, 0)
274
- )
275
-
276
- for _ in range(30): # Hold for 1 second at 30fps
277
- frame_path = os.path.join(output_dir, f"frame_{frame_num:04d}.png")
278
- final_frame.save(frame_path)
279
- frame_paths.append(frame_path)
280
- frame_num += 1
281
-
282
- logger.info(f"Generated {len(frame_paths)} frames")
283
- return frame_paths
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modules/art_reels/services/drawing_animator.py DELETED
@@ -1,303 +0,0 @@
1
- """
2
- Drawing Animator - Line-by-line Code Drawing Animation
3
- Pure Python implementation using Pillow
4
- """
5
- import logging
6
- import math
7
- from PIL import Image, ImageDraw, ImageFont
8
- from typing import List, Tuple, Dict
9
- import os
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class DrawingAnimator:
15
- """
16
- Creates line-by-line drawing animations.
17
-
18
- Features:
19
- - Outline drawing animation
20
- - Color fill animation
21
- - Multiple drawing templates
22
- """
23
-
24
- # Video dimensions (9:16 portrait)
25
- WIDTH = 1080
26
- HEIGHT = 1920
27
-
28
- # Colors
29
- BG_COLOR = (255, 255, 255) # White
30
- OUTLINE_COLOR = (50, 50, 50) # Dark gray
31
- LINE_WIDTH = 4
32
-
33
- # Drawing templates
34
- TEMPLATES = {
35
- "house": {
36
- "lines": [
37
- # Base rectangle
38
- {"start": (340, 1200), "end": (740, 1200), "order": 1}, # bottom
39
- {"start": (740, 1200), "end": (740, 800), "order": 2}, # right
40
- {"start": (740, 800), "end": (340, 800), "order": 3}, # top
41
- {"start": (340, 800), "end": (340, 1200), "order": 4}, # left
42
- # Roof
43
- {"start": (300, 800), "end": (540, 550), "order": 5}, # left roof
44
- {"start": (540, 550), "end": (780, 800), "order": 6}, # right roof
45
- # Door
46
- {"start": (480, 1200), "end": (480, 1000), "order": 7},
47
- {"start": (480, 1000), "end": (600, 1000), "order": 8},
48
- {"start": (600, 1000), "end": (600, 1200), "order": 9},
49
- # Window
50
- {"start": (400, 900), "end": (400, 950), "order": 10},
51
- {"start": (400, 950), "end": (460, 950), "order": 11},
52
- {"start": (460, 950), "end": (460, 900), "order": 12},
53
- {"start": (460, 900), "end": (400, 900), "order": 13},
54
- ],
55
- "fills": [
56
- {"points": [(340, 800), (740, 800), (740, 1200), (340, 1200)], "color": (255, 230, 150), "order": 1},
57
- {"points": [(300, 800), (540, 550), (780, 800)], "color": (180, 80, 80), "order": 2},
58
- {"points": [(480, 1000), (600, 1000), (600, 1200), (480, 1200)], "color": (139, 90, 43), "order": 3},
59
- ]
60
- },
61
- "tree": {
62
- "lines": [
63
- # Trunk
64
- {"start": (500, 1300), "end": (580, 1300), "order": 1},
65
- {"start": (580, 1300), "end": (580, 1000), "order": 2},
66
- {"start": (580, 1000), "end": (500, 1000), "order": 3},
67
- {"start": (500, 1000), "end": (500, 1300), "order": 4},
68
- # Leaves (triangle layers)
69
- {"start": (400, 1000), "end": (540, 750), "order": 5},
70
- {"start": (540, 750), "end": (680, 1000), "order": 6},
71
- {"start": (680, 1000), "end": (400, 1000), "order": 7},
72
- {"start": (420, 850), "end": (540, 600), "order": 8},
73
- {"start": (540, 600), "end": (660, 850), "order": 9},
74
- {"start": (660, 850), "end": (420, 850), "order": 10},
75
- ],
76
- "fills": [
77
- {"points": [(500, 1000), (580, 1000), (580, 1300), (500, 1300)], "color": (139, 90, 43), "order": 1},
78
- {"points": [(400, 1000), (540, 750), (680, 1000)], "color": (34, 139, 34), "order": 2},
79
- {"points": [(420, 850), (540, 600), (660, 850)], "color": (50, 160, 50), "order": 3},
80
- ]
81
- },
82
- "star": {
83
- "lines": [
84
- {"start": (540, 600), "end": (580, 750), "order": 1},
85
- {"start": (580, 750), "end": (740, 750), "order": 2},
86
- {"start": (740, 750), "end": (620, 850), "order": 3},
87
- {"start": (620, 850), "end": (680, 1000), "order": 4},
88
- {"start": (680, 1000), "end": (540, 900), "order": 5},
89
- {"start": (540, 900), "end": (400, 1000), "order": 6},
90
- {"start": (400, 1000), "end": (460, 850), "order": 7},
91
- {"start": (460, 850), "end": (340, 750), "order": 8},
92
- {"start": (340, 750), "end": (500, 750), "order": 9},
93
- {"start": (500, 750), "end": (540, 600), "order": 10},
94
- ],
95
- "fills": [
96
- {"points": [(540, 600), (580, 750), (740, 750), (620, 850), (680, 1000),
97
- (540, 900), (400, 1000), (460, 850), (340, 750), (500, 750)],
98
- "color": (255, 215, 0), "order": 1},
99
- ]
100
- },
101
- "heart": {
102
- "lines": [
103
- # Left curve (approximated with lines)
104
- {"start": (540, 750), "end": (440, 650), "order": 1},
105
- {"start": (440, 650), "end": (380, 700), "order": 2},
106
- {"start": (380, 700), "end": (380, 800), "order": 3},
107
- {"start": (380, 800), "end": (540, 1000), "order": 4},
108
- # Right curve
109
- {"start": (540, 750), "end": (640, 650), "order": 5},
110
- {"start": (640, 650), "end": (700, 700), "order": 6},
111
- {"start": (700, 700), "end": (700, 800), "order": 7},
112
- {"start": (700, 800), "end": (540, 1000), "order": 8},
113
- ],
114
- "fills": [
115
- {"points": [(540, 750), (440, 650), (380, 700), (380, 800), (540, 1000),
116
- (700, 800), (700, 700), (640, 650)],
117
- "color": (255, 100, 100), "order": 1},
118
- ]
119
- }
120
- }
121
-
122
- def __init__(self):
123
- self.frames = []
124
-
125
- def draw_line_animated(
126
- self,
127
- start: Tuple[int, int],
128
- end: Tuple[int, int],
129
- progress: float
130
- ) -> Tuple[Tuple[int, int], Tuple[int, int]]:
131
- """Calculate partial line based on progress (0.0 to 1.0)"""
132
- x1, y1 = start
133
- x2, y2 = end
134
-
135
- current_x = int(x1 + (x2 - x1) * progress)
136
- current_y = int(y1 + (y2 - y1) * progress)
137
-
138
- return (start, (current_x, current_y))
139
-
140
- def create_frame(
141
- self,
142
- template_name: str,
143
- line_progress: Dict[int, float], # order -> progress
144
- fill_progress: Dict[int, bool] # order -> filled
145
- ) -> Image.Image:
146
- """Create a single frame of the drawing animation"""
147
- template = self.TEMPLATES.get(template_name, self.TEMPLATES["house"])
148
-
149
- # Create white background
150
- img = Image.new('RGB', (self.WIDTH, self.HEIGHT), self.BG_COLOR)
151
- draw = ImageDraw.Draw(img)
152
-
153
- # Draw completed fills
154
- for fill in template.get("fills", []):
155
- if fill_progress.get(fill["order"], False):
156
- draw.polygon(fill["points"], fill=fill["color"])
157
-
158
- # Draw lines with progress
159
- for line in template["lines"]:
160
- order = line["order"]
161
- progress = line_progress.get(order, 0.0)
162
-
163
- if progress > 0:
164
- if progress >= 1.0:
165
- # Complete line
166
- draw.line([line["start"], line["end"]],
167
- fill=self.OUTLINE_COLOR, width=self.LINE_WIDTH)
168
- else:
169
- # Partial line
170
- start, end = self.draw_line_animated(
171
- line["start"], line["end"], progress
172
- )
173
- draw.line([start, end],
174
- fill=self.OUTLINE_COLOR, width=self.LINE_WIDTH)
175
-
176
- return img
177
-
178
- def generate_drawing_animation(
179
- self,
180
- subject: str = "house",
181
- output_dir: str = "temp",
182
- with_colors: bool = True,
183
- frames_per_line: int = 15
184
- ) -> List[str]:
185
- """
186
- Generate frame-by-frame drawing animation.
187
-
188
- Args:
189
- subject: What to draw (house, tree, star, heart)
190
- output_dir: Directory to save frames
191
- with_colors: Whether to add color fill after outline
192
- frames_per_line: Frames per line segment
193
-
194
- Returns:
195
- List of frame file paths
196
- """
197
- os.makedirs(output_dir, exist_ok=True)
198
-
199
- template_name = subject.lower()
200
- if template_name not in self.TEMPLATES:
201
- template_name = "house"
202
-
203
- template = self.TEMPLATES[template_name]
204
- lines = sorted(template["lines"], key=lambda x: x["order"])
205
- fills = sorted(template.get("fills", []), key=lambda x: x["order"])
206
-
207
- frame_paths = []
208
- frame_num = 0
209
-
210
- # Track progress
211
- line_progress = {}
212
- fill_progress = {}
213
-
214
- # Animate each line
215
- for line in lines:
216
- order = line["order"]
217
-
218
- for i in range(frames_per_line):
219
- progress = (i + 1) / frames_per_line
220
- line_progress[order] = progress
221
-
222
- # Create frame
223
- frame = self.create_frame(template_name, line_progress, fill_progress)
224
-
225
- # Add "Drawing..." text
226
- draw = ImageDraw.Draw(frame)
227
- try:
228
- font = ImageFont.truetype("arial.ttf", 40)
229
- except:
230
- font = ImageFont.load_default()
231
-
232
- text = f"Drawing {subject.title()}..."
233
- bbox = draw.textbbox((0, 0), text, font=font)
234
- text_width = bbox[2] - bbox[0]
235
- draw.text(
236
- ((self.WIDTH - text_width) // 2, self.HEIGHT - 150),
237
- text,
238
- fill=(100, 100, 100),
239
- font=font
240
- )
241
-
242
- frame_path = os.path.join(output_dir, f"frame_{frame_num:04d}.png")
243
- frame.save(frame_path)
244
- frame_paths.append(frame_path)
245
- frame_num += 1
246
-
247
- # Animate color fills
248
- if with_colors and fills:
249
- for fill in fills:
250
- fill_progress[fill["order"]] = True
251
-
252
- # Hold each fill for some frames
253
- for _ in range(20):
254
- frame = self.create_frame(template_name, line_progress, fill_progress)
255
-
256
- draw = ImageDraw.Draw(frame)
257
- try:
258
- font = ImageFont.truetype("arial.ttf", 40)
259
- except:
260
- font = ImageFont.load_default()
261
-
262
- text = "Adding colors..."
263
- bbox = draw.textbbox((0, 0), text, font=font)
264
- text_width = bbox[2] - bbox[0]
265
- draw.text(
266
- ((self.WIDTH - text_width) // 2, self.HEIGHT - 150),
267
- text,
268
- fill=(100, 100, 100),
269
- font=font
270
- )
271
-
272
- frame_path = os.path.join(output_dir, f"frame_{frame_num:04d}.png")
273
- frame.save(frame_path)
274
- frame_paths.append(frame_path)
275
- frame_num += 1
276
-
277
- # Final frame with "Complete!"
278
- for _ in range(45): # 1.5 seconds
279
- frame = self.create_frame(template_name, line_progress, fill_progress)
280
-
281
- draw = ImageDraw.Draw(frame)
282
- try:
283
- font = ImageFont.truetype("arial.ttf", 60)
284
- except:
285
- font = ImageFont.load_default()
286
-
287
- text = "Done! Complete!"
288
- bbox = draw.textbbox((0, 0), text, font=font)
289
- text_width = bbox[2] - bbox[0]
290
- draw.text(
291
- ((self.WIDTH - text_width) // 2, self.HEIGHT - 150),
292
- text,
293
- fill=(76, 175, 80),
294
- font=font
295
- )
296
-
297
- frame_path = os.path.join(output_dir, f"frame_{frame_num:04d}.png")
298
- frame.save(frame_path)
299
- frame_paths.append(frame_path)
300
- frame_num += 1
301
-
302
- logger.info(f"Generated {len(frame_paths)} frames for drawing animation")
303
- return frame_paths
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modules/art_reels/services/professional_stick_figure.py DELETED
@@ -1,1086 +0,0 @@
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
- # Try both GROQ_API (config) and GROQ_API_KEY env vars
134
- self.groq_api_key = groq_api_key or os.environ.get("GROQ_API") or os.environ.get("GROQ_API_KEY")
135
- if self.groq_api_key:
136
- self.groq = Groq(api_key=self.groq_api_key)
137
- logger.info("Groq API initialized for AI scene generation")
138
- else:
139
- self.groq = None
140
- logger.warning("Groq API key not found (GROQ_API or GROQ_API_KEY) - AI scene generation disabled")
141
-
142
- # Load fonts
143
- self._load_fonts()
144
-
145
- def _load_fonts(self):
146
- """Load fonts with fallbacks"""
147
- try:
148
- self.font_title = ImageFont.truetype("arial.ttf", 72)
149
- self.font_subtitle = ImageFont.truetype("arial.ttf", 48)
150
- self.font_caption = ImageFont.truetype("arial.ttf", 42)
151
- self.font_thought = ImageFont.truetype("arial.ttf", 32)
152
- self.font_label = ImageFont.truetype("arial.ttf", 28)
153
- except:
154
- self.font_title = ImageFont.load_default()
155
- self.font_subtitle = ImageFont.load_default()
156
- self.font_caption = ImageFont.load_default()
157
- self.font_thought = ImageFont.load_default()
158
- self.font_label = ImageFont.load_default()
159
-
160
- # ==========================================
161
- # AI SCENE GENERATION
162
- # ==========================================
163
-
164
- def generate_scenes_with_ai(self, chunks: List[Dict]) -> List[Dict]:
165
- """Generate professional scene descriptions using AI"""
166
- if not self.groq:
167
- return self._generate_fallback_scenes(chunks)
168
-
169
- try:
170
- chunk_texts = "\n".join([
171
- f"Chunk {c.get('chunk_id', i)}: \"{c.get('text', '')}\""
172
- for i, c in enumerate(chunks)
173
- ])
174
-
175
- user_prompt = f"""Create professional whiteboard animation scenes for:
176
-
177
- {chunk_texts}
178
-
179
- Generate exactly {len(chunks)} scenes. Return ONLY valid JSON array."""
180
-
181
- response = self.groq.chat.completions.create(
182
- model="openai/gpt-oss-120b",
183
- messages=[
184
- {"role": "system", "content": PROFESSIONAL_SCENE_PROMPT},
185
- {"role": "user", "content": user_prompt}
186
- ],
187
- temperature=0.7,
188
- max_tokens=4000
189
- )
190
-
191
- content = response.choices[0].message.content.strip()
192
-
193
- # Parse JSON
194
- if "```" in content:
195
- content = content.split("```")[1]
196
- if content.startswith("json"):
197
- content = content[4:]
198
-
199
- scenes = json.loads(content)
200
- logger.info(f"AI generated {len(scenes)} professional scenes")
201
- return scenes
202
-
203
- except Exception as e:
204
- logger.error(f"AI scene generation failed: {e}")
205
- return self._generate_fallback_scenes(chunks)
206
-
207
- def _generate_fallback_scenes(self, chunks: List[Dict]) -> List[Dict]:
208
- """Fallback scene generation with varied poses and emotions"""
209
- scenes = []
210
-
211
- # Pose keywords for text detection
212
- pose_keywords = {
213
- "walking": ["walk", "walked", "forward", "step", "move", "journey"],
214
- "running": ["run", "ran", "fast", "quick", "rush", "hurry"],
215
- "sitting": ["sit", "sat", "rest", "relax", "seat", "chair"],
216
- "sleeping": ["sleep", "slept", "tired", "exhausted", "rest", "night"],
217
- "thinking": ["think", "thought", "wonder", "consider", "decide", "choice"],
218
- "jumping": ["jump", "leap", "excited", "joy", "success", "win"],
219
- "celebrating": ["celebrate", "success", "victory", "won", "achieve", "happy"],
220
- "pointing": ["point", "show", "look", "direction", "this", "that"],
221
- "talking": ["said", "told", "speak", "talk", "explain", "teach"],
222
- "waving": ["hello", "hi", "bye", "goodbye", "wave", "greeting"]
223
- }
224
-
225
- # Emotion keywords
226
- emotion_keywords = {
227
- "happy": ["happy", "joy", "success", "win", "good", "great", "smile"],
228
- "sad": ["sad", "fail", "failed", "lost", "wrong", "bad", "sorry"],
229
- "thinking": ["think", "wonder", "decide", "choose", "question"],
230
- "excited": ["excited", "wow", "amazing", "incredible", "jump", "celebrate"],
231
- "surprised": ["surprise", "shock", "unexpected", "suddenly"],
232
- "confused": ["confused", "wonder", "how", "why", "what"]
233
- }
234
-
235
- # Default poses for variety cycling
236
- default_poses = ["standing", "walking", "pointing", "thinking", "talking", "waving"]
237
- default_emotions = ["happy", "thinking", "excited", "happy"]
238
-
239
- for i, chunk in enumerate(chunks):
240
- text = chunk.get("text", "").lower()
241
-
242
- # Detect pose from text
243
- detected_pose = None
244
- for pose, keywords in pose_keywords.items():
245
- if any(kw in text for kw in keywords):
246
- detected_pose = pose
247
- break
248
-
249
- # If no pose detected, cycle through defaults
250
- if not detected_pose:
251
- detected_pose = default_poses[i % len(default_poses)]
252
-
253
- # Detect emotion from text
254
- detected_emotion = None
255
- for emotion, keywords in emotion_keywords.items():
256
- if any(kw in text for kw in keywords):
257
- detected_emotion = emotion
258
- break
259
-
260
- # If no emotion detected, cycle through defaults
261
- if not detected_emotion:
262
- detected_emotion = default_emotions[i % len(default_emotions)]
263
-
264
- # Detect scene type
265
- scene_type = "single"
266
- if any(word in text for word in ["vs", "versus", "compare", "between", "both", "two"]):
267
- scene_type = "dual"
268
- elif any(word in text for word in ["introduce", "what is", "explained", "definition"]):
269
- scene_type = "title"
270
-
271
- # Build scene
272
- scene = {
273
- "chunk_id": i,
274
- "scene_type": scene_type,
275
- "background": "white",
276
- "title_text": text[:30].upper() if scene_type == "title" else None,
277
- "characters": [
278
- {
279
- "position": "center",
280
- "pose": detected_pose,
281
- "emotion": detected_emotion,
282
- "props": []
283
- }
284
- ] if scene_type != "title" else [],
285
- "caption": text[:50] + "..." if len(text) > 50 else text
286
- }
287
-
288
- scenes.append(scene)
289
- logger.info(f"Fallback scene {i}: pose={detected_pose}, emotion={detected_emotion}")
290
-
291
- return scenes
292
-
293
- # ==========================================
294
- # BACKGROUND DRAWING
295
- # ==========================================
296
-
297
- def draw_background(self, draw: ImageDraw, bg_type: str = "white"):
298
- """Draw scene background"""
299
- if bg_type == "blackboard":
300
- # Green chalkboard
301
- draw.rectangle([0, 0, self.WIDTH, self.HEIGHT], fill=(34, 49, 39))
302
- # Frame
303
- draw.rectangle([30, 200, self.WIDTH - 30, 900],
304
- outline=(139, 90, 43), width=15)
305
- draw.rectangle([40, 210, self.WIDTH - 40, 890],
306
- fill=(40, 68, 50))
307
-
308
- elif bg_type == "office":
309
- # Light gray wall
310
- draw.rectangle([0, 0, self.WIDTH, self.HEIGHT], fill=(240, 240, 240))
311
- # Floor
312
- draw.rectangle([0, 1400, self.WIDTH, self.HEIGHT], fill=(180, 150, 120))
313
- # Simple desk
314
- draw.rectangle([200, 1200, 880, 1250], fill=(139, 90, 43))
315
- draw.rectangle([250, 1250, 300, 1400], fill=(100, 65, 30))
316
- draw.rectangle([780, 1250, 830, 1400], fill=(100, 65, 30))
317
-
318
- elif bg_type == "outdoor":
319
- # Sky gradient (simplified)
320
- draw.rectangle([0, 0, self.WIDTH, 600], fill=(135, 206, 235))
321
- # Ground
322
- draw.rectangle([0, 1400, self.WIDTH, self.HEIGHT], fill=(76, 153, 0))
323
- # Sun
324
- draw.ellipse([850, 100, 1000, 250], fill=(255, 223, 0))
325
-
326
- elif bg_type == "room":
327
- # Wall
328
- draw.rectangle([0, 0, self.WIDTH, self.HEIGHT], fill=(255, 248, 240))
329
- # Floor
330
- draw.rectangle([0, 1400, self.WIDTH, self.HEIGHT], fill=(139, 119, 101))
331
-
332
- # Default white - no additional drawing needed
333
-
334
- # ==========================================
335
- # TITLE SCREEN
336
- # ==========================================
337
-
338
- def draw_title_screen(
339
- self,
340
- img: Image.Image,
341
- title: str,
342
- subtitle: str = None
343
- ):
344
- """Draw professional title screen"""
345
- draw = ImageDraw.Draw(img)
346
-
347
- # Main title
348
- bbox = draw.textbbox((0, 0), title, font=self.font_title)
349
- title_width = bbox[2] - bbox[0]
350
- title_x = (self.WIDTH - title_width) // 2
351
- title_y = self.HEIGHT // 2 - 100
352
-
353
- # Draw title with underline
354
- draw.text((title_x, title_y), title, fill=self.TITLE_COLOR, font=self.font_title)
355
-
356
- # Underline
357
- draw.line([title_x, title_y + 90, title_x + title_width, title_y + 90],
358
- fill=self.ACCENT_COLOR, width=4)
359
-
360
- # Subtitle if provided
361
- if subtitle:
362
- bbox = draw.textbbox((0, 0), subtitle, font=self.font_subtitle)
363
- sub_width = bbox[2] - bbox[0]
364
- sub_x = (self.WIDTH - sub_width) // 2
365
- draw.text((sub_x, title_y + 120), subtitle,
366
- fill=(100, 100, 100), font=self.font_subtitle)
367
-
368
- # ==========================================
369
- # CAPTION (Bottom Bar)
370
- # ==========================================
371
-
372
- def draw_caption(
373
- self,
374
- img: Image.Image,
375
- caption: str,
376
- position: str = "bottom"
377
- ):
378
- """Draw professional caption bar"""
379
- if not caption:
380
- return
381
-
382
- draw = ImageDraw.Draw(img)
383
-
384
- # Caption background
385
- caption_height = 120
386
- if position == "bottom":
387
- caption_y = self.HEIGHT - caption_height - 50
388
- else:
389
- caption_y = 50
390
-
391
- # Semi-transparent background
392
- draw.rectangle([40, caption_y, self.WIDTH - 40, caption_y + caption_height],
393
- fill=(0, 0, 0), outline=None)
394
-
395
- # Text
396
- bbox = draw.textbbox((0, 0), caption, font=self.font_caption)
397
- text_width = bbox[2] - bbox[0]
398
- text_x = (self.WIDTH - text_width) // 2
399
- text_y = caption_y + (caption_height - 42) // 2
400
-
401
- draw.text((text_x, text_y), caption, fill=self.CAPTION_TEXT, font=self.font_caption)
402
-
403
- # ==========================================
404
- # THOUGHT BUBBLE
405
- # ==========================================
406
-
407
- def draw_thought_bubble(
408
- self,
409
- draw: ImageDraw,
410
- x: int,
411
- y: int,
412
- text: str,
413
- direction: str = "right"
414
- ):
415
- """Draw thought bubble with text"""
416
- # Bubble dimensions
417
- padding = 20
418
- bbox = draw.textbbox((0, 0), text, font=self.font_thought)
419
- text_width = bbox[2] - bbox[0]
420
- text_height = bbox[3] - bbox[1]
421
-
422
- bubble_width = text_width + padding * 2
423
- bubble_height = text_height + padding * 2
424
-
425
- # Position bubble
426
- if direction == "right":
427
- bubble_x = x + 30
428
- else:
429
- bubble_x = x - bubble_width - 30
430
- bubble_y = y - 150
431
-
432
- # Draw bubble (ellipse)
433
- draw.ellipse([bubble_x, bubble_y, bubble_x + bubble_width, bubble_y + bubble_height],
434
- fill=self.THOUGHT_BG, outline=self.FIGURE_COLOR, width=2)
435
-
436
- # Small circles leading to head
437
- for i, offset in enumerate([(15, 40), (8, 60), (3, 75)]):
438
- size = 12 - i * 3
439
- cx = x + (offset[0] if direction == "right" else -offset[0])
440
- cy = y - 80 + offset[1]
441
- draw.ellipse([cx - size, cy - size, cx + size, cy + size],
442
- fill=self.THOUGHT_BG, outline=self.FIGURE_COLOR, width=2)
443
-
444
- # Text
445
- draw.text((bubble_x + padding, bubble_y + padding), text,
446
- fill=self.FIGURE_COLOR, font=self.font_thought)
447
-
448
- # ==========================================
449
- # STICK FIGURE DRAWING
450
- # ==========================================
451
-
452
- def draw_stick_figure(
453
- self,
454
- draw: ImageDraw,
455
- x: int,
456
- y: int,
457
- pose: str = "standing",
458
- emotion: str = "happy",
459
- props: List[str] = None,
460
- scale: float = 1.0,
461
- facing: str = "front" # front, left, right
462
- ):
463
- """Draw expressive stick figure"""
464
- props = props or []
465
-
466
- # Scale dimensions
467
- head_r = int(self.HEAD_RADIUS * scale)
468
- body_len = int(self.BODY_LENGTH * scale)
469
- arm_len = int(self.ARM_LENGTH * scale)
470
- leg_len = int(self.LEG_LENGTH * scale)
471
- line_w = max(4, int(self.LINE_WIDTH * scale))
472
-
473
- # Draw based on pose
474
- pose_methods = {
475
- "standing": self._draw_standing,
476
- "walking": self._draw_walking,
477
- "running": self._draw_running,
478
- "sitting": self._draw_sitting,
479
- "sleeping": self._draw_sleeping,
480
- "waving": self._draw_waving,
481
- "thinking": self._draw_thinking,
482
- "jumping": self._draw_jumping,
483
- "celebrating": self._draw_celebrating,
484
- "pointing": self._draw_pointing,
485
- "talking": self._draw_talking,
486
- }
487
-
488
- draw_method = pose_methods.get(pose, self._draw_standing)
489
- draw_method(draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion)
490
-
491
- # Draw props
492
- for prop in props:
493
- self._draw_prop(draw, x, y - body_len - head_r, head_r, prop, scale)
494
-
495
- def _draw_face(self, draw, x, y, head_r, emotion, line_w):
496
- """Draw expressive face"""
497
- eye_y = y - head_r // 4
498
- eye_offset = head_r // 3
499
- eye_size = head_r // 5
500
-
501
- if emotion == "happy":
502
- # Happy curved eyes
503
- draw.arc([x - eye_offset - eye_size, eye_y - eye_size,
504
- x - eye_offset + eye_size, eye_y + eye_size],
505
- 0, 180, fill=self.FIGURE_COLOR, width=line_w//2)
506
- draw.arc([x + eye_offset - eye_size, eye_y - eye_size,
507
- x + eye_offset + eye_size, eye_y + eye_size],
508
- 0, 180, fill=self.FIGURE_COLOR, width=line_w//2)
509
- # Smile
510
- draw.arc([x - head_r//2, y - head_r//4, x + head_r//2, y + head_r//2],
511
- 0, 180, fill=self.FIGURE_COLOR, width=line_w//2)
512
-
513
- elif emotion == "sad":
514
- draw.ellipse([x - eye_offset - eye_size, eye_y - eye_size,
515
- x - eye_offset + eye_size, eye_y + eye_size],
516
- fill=self.FIGURE_COLOR)
517
- draw.ellipse([x + eye_offset - eye_size, eye_y - eye_size,
518
- x + eye_offset + eye_size, eye_y + eye_size],
519
- fill=self.FIGURE_COLOR)
520
- # Frown
521
- draw.arc([x - head_r//2, y, x + head_r//2, y + head_r//2],
522
- 180, 360, fill=self.FIGURE_COLOR, width=line_w//2)
523
-
524
- elif emotion == "thinking":
525
- draw.ellipse([x - eye_offset - eye_size, eye_y - eye_size - 5,
526
- x - eye_offset + eye_size, eye_y + eye_size - 5],
527
- fill=self.FIGURE_COLOR)
528
- draw.ellipse([x + eye_offset - eye_size, eye_y - eye_size - 5,
529
- x + eye_offset + eye_size, eye_y + eye_size - 5],
530
- fill=self.FIGURE_COLOR)
531
- # Neutral
532
- draw.line([x - head_r//3, y + head_r//4, x + head_r//3, y + head_r//4],
533
- fill=self.FIGURE_COLOR, width=line_w//2)
534
-
535
- elif emotion == "excited":
536
- big_eye = eye_size * 2
537
- draw.ellipse([x - eye_offset - big_eye, eye_y - big_eye,
538
- x - eye_offset + big_eye, eye_y + big_eye],
539
- fill=self.FIGURE_COLOR)
540
- draw.ellipse([x + eye_offset - big_eye, eye_y - big_eye,
541
- x + eye_offset + big_eye, eye_y + big_eye],
542
- fill=self.FIGURE_COLOR)
543
- draw.arc([x - head_r//2, y - head_r//3, x + head_r//2, y + head_r//2],
544
- 0, 180, fill=self.FIGURE_COLOR, width=line_w)
545
-
546
- elif emotion == "surprised":
547
- # Wide eyes
548
- draw.ellipse([x - eye_offset - eye_size*2, eye_y - eye_size*2,
549
- x - eye_offset + eye_size*2, eye_y + eye_size*2],
550
- outline=self.FIGURE_COLOR, width=line_w//2)
551
- draw.ellipse([x + eye_offset - eye_size*2, eye_y - eye_size*2,
552
- x + eye_offset + eye_size*2, eye_y + eye_size*2],
553
- outline=self.FIGURE_COLOR, width=line_w//2)
554
- # O mouth
555
- draw.ellipse([x - head_r//4, y + head_r//8, x + head_r//4, y + head_r//2],
556
- outline=self.FIGURE_COLOR, width=line_w//2)
557
-
558
- else:
559
- # Default neutral
560
- draw.ellipse([x - eye_offset - eye_size, eye_y - eye_size,
561
- x - eye_offset + eye_size, eye_y + eye_size],
562
- fill=self.FIGURE_COLOR)
563
- draw.ellipse([x + eye_offset - eye_size, eye_y - eye_size,
564
- x + eye_offset + eye_size, eye_y + eye_size],
565
- fill=self.FIGURE_COLOR)
566
- draw.line([x - head_r//3, y + head_r//4, x + head_r//3, y + head_r//4],
567
- fill=self.FIGURE_COLOR, width=line_w//2)
568
-
569
- def _draw_standing(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
570
- """Draw standing pose"""
571
- head_y = y - body_len - head_r
572
-
573
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
574
- outline=self.FIGURE_COLOR, width=line_w)
575
- self._draw_face(draw, x, head_y, head_r, emotion, line_w)
576
-
577
- body_top = head_y + head_r
578
- body_bottom = body_top + body_len
579
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
580
-
581
- arm_y = body_top + body_len // 4
582
- draw.line([x, arm_y, x - arm_len, arm_y + arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
583
- draw.line([x, arm_y, x + arm_len, arm_y + arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
584
-
585
- draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
586
- draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
587
-
588
- def _draw_walking(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
589
- """Draw walking pose"""
590
- head_y = y - body_len - head_r
591
-
592
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
593
- outline=self.FIGURE_COLOR, width=line_w)
594
- self._draw_face(draw, x, head_y, head_r, emotion, line_w)
595
-
596
- body_top = head_y + head_r
597
- body_bottom = body_top + body_len
598
- draw.line([x, body_top, x + 10, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
599
-
600
- arm_y = body_top + body_len // 4
601
- draw.line([x, arm_y, x - arm_len//2, arm_y + arm_len], fill=self.FIGURE_COLOR, width=line_w)
602
- draw.line([x, arm_y, x + arm_len, arm_y - arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
603
-
604
- draw.line([x + 10, body_bottom, x - leg_len, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
605
- draw.line([x + 10, body_bottom, x + leg_len, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
606
-
607
- def _draw_running(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
608
- """Draw running pose"""
609
- head_y = y - body_len - head_r
610
-
611
- draw.ellipse([x + 20 - head_r, head_y - head_r, x + 20 + head_r, head_y + head_r],
612
- outline=self.FIGURE_COLOR, width=line_w)
613
- self._draw_face(draw, x + 20, head_y, head_r, "excited", line_w)
614
-
615
- body_top = head_y + head_r
616
- body_bottom = body_top + body_len
617
- draw.line([x + 20, body_top, x + 40, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
618
-
619
- arm_y = body_top + body_len // 4 + 20
620
- draw.line([x + 20, arm_y, x - arm_len, arm_y + arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
621
- draw.line([x + 20, arm_y, x + arm_len + 30, arm_y - arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
622
-
623
- draw.line([x + 40, body_bottom, x - leg_len - 20, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
624
- draw.line([x + 40, body_bottom, x + leg_len + 40, body_bottom + leg_len//3], fill=self.FIGURE_COLOR, width=line_w)
625
-
626
- def _draw_sitting(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
627
- """Draw sitting pose"""
628
- y_offset = leg_len // 2
629
- head_y = y - body_len - head_r + y_offset
630
-
631
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
632
- outline=self.FIGURE_COLOR, width=line_w)
633
- self._draw_face(draw, x, head_y, head_r, emotion, line_w)
634
-
635
- body_top = head_y + head_r
636
- body_bottom = body_top + body_len // 2
637
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
638
-
639
- arm_y = body_top + body_len // 6
640
- draw.line([x, arm_y, x - arm_len // 2, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
641
- draw.line([x, arm_y, x + arm_len // 2, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
642
-
643
- # Chair
644
- draw.line([x - leg_len, body_bottom, x + leg_len, body_bottom], fill=self.FIGURE_COLOR, width=line_w//2)
645
- draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
646
- draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
647
-
648
- def _draw_sleeping(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
649
- """Draw sleeping pose (horizontal)"""
650
- draw.ellipse([x - body_len - head_r, y - head_r, x - body_len + head_r, y + head_r],
651
- outline=self.FIGURE_COLOR, width=line_w)
652
-
653
- # Closed eyes
654
- draw.line([x - body_len - head_r//2, y - head_r//4, x - body_len - head_r//4, y - head_r//4],
655
- fill=self.FIGURE_COLOR, width=line_w//2)
656
- draw.line([x - body_len + head_r//4, y - head_r//4, x - body_len + head_r//2, y - head_r//4],
657
- fill=self.FIGURE_COLOR, width=line_w//2)
658
-
659
- draw.line([x - body_len + head_r, y, x, y], fill=self.FIGURE_COLOR, width=line_w)
660
- draw.line([x, y, x + leg_len, y + 10], fill=self.FIGURE_COLOR, width=line_w)
661
- draw.line([x, y, x + leg_len, y - 10], fill=self.FIGURE_COLOR, width=line_w)
662
-
663
- # Zzz
664
- draw.text((x - body_len, y - head_r - 50), "Zzz", fill=(150, 150, 150), font=self.font_label)
665
-
666
- # Bed
667
- draw.rectangle([x - body_len - head_r - 20, y + head_r, x + leg_len + 20, y + head_r + 20],
668
- fill=(139, 90, 43))
669
-
670
- def _draw_waving(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
671
- """Draw waving pose"""
672
- head_y = y - body_len - head_r
673
-
674
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
675
- outline=self.FIGURE_COLOR, width=line_w)
676
- self._draw_face(draw, x, head_y, head_r, "happy", line_w)
677
-
678
- body_top = head_y + head_r
679
- body_bottom = body_top + body_len
680
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
681
-
682
- arm_y = body_top + body_len // 4
683
- draw.line([x, arm_y, x - arm_len, arm_y + arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
684
- draw.line([x, arm_y, x + arm_len//2, arm_y - arm_len], fill=self.FIGURE_COLOR, width=line_w)
685
-
686
- # Wave lines
687
- draw.line([x + arm_len//2 - 10, arm_y - arm_len - 30, x + arm_len//2 + 10, arm_y - arm_len - 20],
688
- fill=self.ACCENT_COLOR, width=3)
689
-
690
- draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
691
- draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
692
-
693
- def _draw_thinking(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
694
- """Draw thinking pose"""
695
- head_y = y - body_len - head_r
696
-
697
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
698
- outline=self.FIGURE_COLOR, width=line_w)
699
- self._draw_face(draw, x, head_y, head_r, "thinking", line_w)
700
-
701
- body_top = head_y + head_r
702
- body_bottom = body_top + body_len
703
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
704
-
705
- arm_y = body_top + body_len // 4
706
- draw.line([x, arm_y, x - arm_len, arm_y + arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
707
- # Hand on chin
708
- draw.line([x, arm_y, x + arm_len//3, arm_y - arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
709
- draw.line([x + arm_len//3, arm_y - arm_len//3, x + head_r//2, head_y + head_r],
710
- fill=self.FIGURE_COLOR, width=line_w)
711
-
712
- draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
713
- draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
714
-
715
- def _draw_jumping(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
716
- """Draw jumping pose"""
717
- y_offset = -80
718
- head_y = y - body_len - head_r + y_offset
719
-
720
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
721
- outline=self.FIGURE_COLOR, width=line_w)
722
- self._draw_face(draw, x, head_y, head_r, "excited", line_w)
723
-
724
- body_top = head_y + head_r
725
- body_bottom = body_top + body_len
726
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
727
-
728
- arm_y = body_top + body_len // 4
729
- draw.line([x, arm_y, x - arm_len, arm_y - arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
730
- draw.line([x, arm_y, x + arm_len, arm_y - arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
731
-
732
- draw.line([x, body_bottom, x - leg_len, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
733
- draw.line([x, body_bottom, x + leg_len, body_bottom + leg_len//2], fill=self.FIGURE_COLOR, width=line_w)
734
-
735
- # Jump line
736
- draw.line([x - 30, body_bottom + leg_len + 30, x + 30, body_bottom + leg_len + 30],
737
- fill=(200, 200, 200), width=4)
738
-
739
- def _draw_celebrating(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
740
- """Draw celebrating pose"""
741
- head_y = y - body_len - head_r
742
-
743
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
744
- outline=self.FIGURE_COLOR, width=line_w)
745
- self._draw_face(draw, x, head_y, head_r, "excited", line_w)
746
-
747
- body_top = head_y + head_r
748
- body_bottom = body_top + body_len
749
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
750
-
751
- arm_y = body_top + body_len // 4
752
- draw.line([x, arm_y, x - arm_len, arm_y - arm_len], fill=self.FIGURE_COLOR, width=line_w)
753
- draw.line([x, arm_y, x + arm_len, arm_y - arm_len], fill=self.FIGURE_COLOR, width=line_w)
754
-
755
- draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
756
- draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
757
-
758
- # Confetti
759
- colors = [(255, 87, 51), (255, 195, 0), (76, 175, 80), (33, 150, 243)]
760
- for _ in range(8):
761
- cx = x + random.randint(-120, 120)
762
- cy = head_y + random.randint(-120, 40)
763
- color = random.choice(colors)
764
- draw.ellipse([cx - 4, cy - 4, cx + 4, cy + 4], fill=color)
765
-
766
- def _draw_pointing(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
767
- """Draw pointing pose"""
768
- head_y = y - body_len - head_r
769
-
770
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
771
- outline=self.FIGURE_COLOR, width=line_w)
772
- self._draw_face(draw, x, head_y, head_r, emotion, line_w)
773
-
774
- body_top = head_y + head_r
775
- body_bottom = body_top + body_len
776
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
777
-
778
- arm_y = body_top + body_len // 4
779
- draw.line([x, arm_y, x - arm_len, arm_y + arm_len//3], fill=self.FIGURE_COLOR, width=line_w)
780
- # Pointing arm
781
- draw.line([x, arm_y, x + arm_len + 20, arm_y - arm_len//2], fill=self.FIGURE_COLOR, width=line_w)
782
-
783
- draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
784
- draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
785
-
786
- def _draw_talking(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w, emotion):
787
- """Draw talking pose with speech lines"""
788
- head_y = y - body_len - head_r
789
-
790
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
791
- outline=self.FIGURE_COLOR, width=line_w)
792
-
793
- # Open mouth
794
- eye_y = head_y - head_r // 4
795
- eye_offset = head_r // 3
796
- eye_size = head_r // 5
797
- draw.ellipse([x - eye_offset - eye_size, eye_y - eye_size,
798
- x - eye_offset + eye_size, eye_y + eye_size],
799
- fill=self.FIGURE_COLOR)
800
- draw.ellipse([x + eye_offset - eye_size, eye_y - eye_size,
801
- x + eye_offset + eye_size, eye_y + eye_size],
802
- fill=self.FIGURE_COLOR)
803
- # Talking mouth
804
- draw.ellipse([x - head_r//3, head_y + head_r//6, x + head_r//3, head_y + head_r//2],
805
- outline=self.FIGURE_COLOR, width=line_w//2)
806
-
807
- # Speech lines
808
- for i in range(3):
809
- sx = x + head_r + 10 + i * 15
810
- sy = head_y - 10 + i * 5
811
- draw.line([sx, sy, sx + 20, sy], fill=(150, 150, 150), width=3)
812
-
813
- body_top = head_y + head_r
814
- body_bottom = body_top + body_len
815
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
816
-
817
- arm_y = body_top + body_len // 4
818
- draw.line([x, arm_y, x - arm_len, arm_y + arm_len//4], fill=self.FIGURE_COLOR, width=line_w)
819
- draw.line([x, arm_y, x + arm_len, arm_y + arm_len//4], fill=self.FIGURE_COLOR, width=line_w)
820
-
821
- draw.line([x, body_bottom, x - leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
822
- draw.line([x, body_bottom, x + leg_len//2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
823
-
824
- # ==========================================
825
- # PROPS DRAWING
826
- # ==========================================
827
-
828
- def _draw_prop(self, draw, x, head_y, head_r, prop: str, scale: float):
829
- """Draw props"""
830
- if prop == "crown":
831
- crown_y = head_y - head_r - 15
832
- points = [
833
- (x - 35, crown_y),
834
- (x - 20, crown_y - 35),
835
- (x, crown_y - 15),
836
- (x + 20, crown_y - 35),
837
- (x + 35, crown_y)
838
- ]
839
- draw.polygon(points, fill=(255, 215, 0), outline=(200, 170, 0), width=2)
840
-
841
- elif prop == "glasses":
842
- glass_y = head_y - head_r // 4
843
- # Left lens
844
- draw.ellipse([x - head_r//2 - 15, glass_y - 12, x - head_r//2 + 15, glass_y + 12],
845
- outline=self.FIGURE_COLOR, width=3)
846
- # Right lens
847
- draw.ellipse([x + head_r//2 - 15, glass_y - 12, x + head_r//2 + 15, glass_y + 12],
848
- outline=self.FIGURE_COLOR, width=3)
849
- # Bridge
850
- draw.line([x - head_r//2 + 15, glass_y, x + head_r//2 - 15, glass_y],
851
- fill=self.FIGURE_COLOR, width=2)
852
-
853
- elif prop == "money":
854
- bag_x = x + 80
855
- bag_y = head_y + 120
856
- draw.ellipse([bag_x, bag_y, bag_x + 50, bag_y + 60],
857
- fill=(34, 139, 34), outline=(20, 100, 20), width=2)
858
- draw.text((bag_x + 18, bag_y + 15), "$", fill=(255, 255, 255), font=self.font_label)
859
-
860
- elif prop == "book":
861
- book_x = x + 70
862
- book_y = head_y + 100
863
- draw.rectangle([book_x, book_y, book_x + 40, book_y + 55],
864
- fill=(139, 69, 19), outline=(100, 50, 10), width=2)
865
- draw.line([book_x + 20, book_y, book_x + 20, book_y + 55], fill=(80, 40, 10), width=2)
866
-
867
- elif prop == "briefcase":
868
- bc_x = x + 70
869
- bc_y = head_y + 130
870
- draw.rectangle([bc_x, bc_y, bc_x + 50, bc_y + 35],
871
- fill=(60, 40, 30), outline=(40, 25, 15), width=2)
872
- draw.rectangle([bc_x + 15, bc_y - 8, bc_x + 35, bc_y + 5],
873
- fill=(60, 40, 30), outline=(40, 25, 15), width=2)
874
-
875
- elif prop == "phone":
876
- phone_x = x + 75
877
- phone_y = head_y + 80
878
- draw.rectangle([phone_x, phone_y, phone_x + 25, phone_y + 40],
879
- fill=(50, 50, 50), outline=(30, 30, 30), width=2)
880
- draw.rectangle([phone_x + 3, phone_y + 5, phone_x + 22, phone_y + 32],
881
- fill=(100, 150, 200))
882
-
883
- # ==========================================
884
- # VISUAL METAPHORS
885
- # ==========================================
886
-
887
- def draw_metaphor(
888
- self,
889
- draw: ImageDraw,
890
- metaphor: str,
891
- state: str = "intact",
892
- y_pos: int = None
893
- ):
894
- """Draw visual metaphors"""
895
- y = y_pos or self.HEIGHT // 2 + 200
896
-
897
- if metaphor == "bridge":
898
- self._draw_bridge(draw, y, state)
899
- elif metaphor == "rope":
900
- self._draw_rope(draw, y, state)
901
- elif metaphor == "scale":
902
- self._draw_scale(draw, y, state)
903
- elif metaphor == "handshake":
904
- self._draw_handshake(draw, y)
905
-
906
- def _draw_bridge(self, draw, y, state):
907
- """Draw bridge metaphor"""
908
- bridge_y = y
909
-
910
- if state == "intact":
911
- # Full bridge
912
- draw.rectangle([200, bridge_y, 880, bridge_y + 30],
913
- fill=(139, 90, 43), outline=(100, 65, 30), width=2)
914
- # Supports
915
- draw.rectangle([200, bridge_y + 30, 250, bridge_y + 150], fill=(100, 65, 30))
916
- draw.rectangle([830, bridge_y + 30, 880, bridge_y + 150], fill=(100, 65, 30))
917
-
918
- elif state == "breaking":
919
- # Left part
920
- draw.polygon([
921
- (200, bridge_y), (500, bridge_y), (520, bridge_y + 80),
922
- (200, bridge_y + 30)
923
- ], fill=(139, 90, 43), outline=(100, 65, 30))
924
- # Right part
925
- draw.polygon([
926
- (560, bridge_y + 60), (880, bridge_y), (880, bridge_y + 30),
927
- (580, bridge_y + 90)
928
- ], fill=(139, 90, 43), outline=(100, 65, 30))
929
- # Supports
930
- draw.rectangle([200, bridge_y + 30, 250, bridge_y + 150], fill=(100, 65, 30))
931
- draw.rectangle([830, bridge_y + 30, 880, bridge_y + 150], fill=(100, 65, 30))
932
-
933
- def _draw_rope(self, draw, y, state):
934
- """Draw rope/tug-of-war metaphor"""
935
- rope_y = y
936
-
937
- # Rope
938
- if state == "tense":
939
- # Straight tense rope
940
- draw.line([200, rope_y, 880, rope_y], fill=(139, 90, 43), width=8)
941
- else:
942
- # Slight curve
943
- for i in range(20):
944
- x1 = 200 + i * 34
945
- x2 = x1 + 34
946
- y1 = rope_y + math.sin(i * 0.5) * 10
947
- y2 = rope_y + math.sin((i + 1) * 0.5) * 10
948
- draw.line([x1, y1, x2, y2], fill=(139, 90, 43), width=8)
949
-
950
- # Center marker
951
- draw.rectangle([530, rope_y - 15, 550, rope_y + 15], fill=self.HIGHLIGHT)
952
-
953
- def _draw_scale(self, draw, y, state):
954
- """Draw balance scale metaphor"""
955
- center_x = self.WIDTH // 2
956
- base_y = y + 100
957
-
958
- # Base
959
- draw.polygon([
960
- (center_x - 50, base_y), (center_x + 50, base_y),
961
- (center_x + 30, base_y + 40), (center_x - 30, base_y + 40)
962
- ], fill=(139, 90, 43))
963
-
964
- # Pole
965
- draw.rectangle([center_x - 5, y - 50, center_x + 5, base_y], fill=(100, 65, 30))
966
-
967
- # Beam
968
- if state == "balanced":
969
- draw.rectangle([center_x - 150, y - 55, center_x + 150, y - 45], fill=(100, 65, 30))
970
- # Plates
971
- draw.ellipse([center_x - 180, y - 30, center_x - 100, y], fill=(180, 150, 120))
972
- draw.ellipse([center_x + 100, y - 30, center_x + 180, y], fill=(180, 150, 120))
973
- else:
974
- # Tilted beam
975
- draw.polygon([
976
- (center_x - 150, y - 30), (center_x + 150, y - 70),
977
- (center_x + 150, y - 60), (center_x - 150, y - 20)
978
- ], fill=(100, 65, 30))
979
-
980
- def _draw_handshake(self, draw, y):
981
- """Draw handshake"""
982
- center_x = self.WIDTH // 2
983
-
984
- # Left hand
985
- draw.line([center_x - 100, y, center_x - 20, y], fill=self.FIGURE_COLOR, width=10)
986
- draw.ellipse([center_x - 40, y - 15, center_x, y + 15], fill=self.FIGURE_COLOR)
987
-
988
- # Right hand
989
- draw.line([center_x + 100, y, center_x + 20, y], fill=self.FIGURE_COLOR, width=10)
990
- draw.ellipse([center_x, y - 15, center_x + 40, y + 15], fill=self.FIGURE_COLOR)
991
-
992
- # Grip
993
- draw.ellipse([center_x - 15, y - 10, center_x + 15, y + 10], fill=self.FIGURE_COLOR)
994
-
995
- # ==========================================
996
- # SCENE RENDERING
997
- # ==========================================
998
-
999
- def create_scene_frame(self, scene: Dict) -> Image.Image:
1000
- """Create a complete scene frame"""
1001
- img = Image.new('RGB', (self.WIDTH, self.HEIGHT), self.BG_COLOR)
1002
- draw = ImageDraw.Draw(img)
1003
-
1004
- scene_type = scene.get("scene_type", "single")
1005
- background = scene.get("background", "white")
1006
-
1007
- # Draw background
1008
- self.draw_background(draw, background)
1009
-
1010
- # Title scene
1011
- if scene_type == "title":
1012
- title = scene.get("title_text", "")
1013
- subtitle = scene.get("subtitle_text")
1014
- if title:
1015
- self.draw_title_screen(img, title, subtitle)
1016
-
1017
- # Character scenes
1018
- else:
1019
- characters = scene.get("characters", [])
1020
- for char in characters:
1021
- pos = char.get("position", "center")
1022
- x = self.POSITIONS.get(pos, self.POSITIONS["center"])
1023
- y = self.CHARACTER_Y
1024
-
1025
- pose = char.get("pose", "standing")
1026
- emotion = char.get("emotion", "happy")
1027
- props = char.get("props", [])
1028
-
1029
- self.draw_stick_figure(draw, x, y, pose, emotion, props, scale=1.2)
1030
-
1031
- # Thought bubble
1032
- thought = char.get("thought_bubble")
1033
- if thought:
1034
- direction = "right" if pos == "left" else "left"
1035
- head_y = y - self.BODY_LENGTH * 1.2 - self.HEAD_RADIUS * 1.2
1036
- self.draw_thought_bubble(draw, x, int(head_y), thought, direction)
1037
-
1038
- # Label
1039
- label = char.get("label")
1040
- if label:
1041
- bbox = draw.textbbox((0, 0), label, font=self.font_label)
1042
- label_width = bbox[2] - bbox[0]
1043
- draw.text((x - label_width // 2, y + 130), label,
1044
- fill=(100, 100, 100), font=self.font_label)
1045
-
1046
- # Draw metaphor
1047
- metaphor = scene.get("metaphor")
1048
- if metaphor:
1049
- state = scene.get("metaphor_state", "intact")
1050
- self.draw_metaphor(draw, metaphor, state)
1051
-
1052
- # Draw caption
1053
- caption = scene.get("caption")
1054
- if caption:
1055
- self.draw_caption(img, caption)
1056
-
1057
- return img
1058
-
1059
- def generate_frames_from_scenes(
1060
- self,
1061
- scenes: List[Dict],
1062
- chunk_durations: List[float],
1063
- output_dir: str,
1064
- fps: int = 30
1065
- ) -> List[str]:
1066
- """Generate frames for all scenes"""
1067
- os.makedirs(output_dir, exist_ok=True)
1068
- frame_paths = []
1069
- frame_num = 0
1070
-
1071
- for i, scene in enumerate(scenes):
1072
- duration = chunk_durations[i] if i < len(chunk_durations) else 2.0
1073
- num_frames = int(duration * fps)
1074
-
1075
- logger.info(f"Generating {num_frames} frames for scene {i}: {scene.get('scene_type', 'unknown')}")
1076
-
1077
- frame = self.create_scene_frame(scene)
1078
-
1079
- for _ in range(num_frames):
1080
- frame_path = os.path.join(output_dir, f"frame_{frame_num:05d}.png")
1081
- frame.save(frame_path)
1082
- frame_paths.append(frame_path)
1083
- frame_num += 1
1084
-
1085
- logger.info(f"Generated {len(frame_paths)} total frames")
1086
- return frame_paths
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modules/art_reels/services/stick_figure.py DELETED
@@ -1,375 +0,0 @@
1
- """
2
- Stick Figure - Simple Stick Figure Drawing for Motivation Videos
3
- Pure Python implementation using Pillow
4
- """
5
- import logging
6
- import math
7
- from PIL import Image, ImageDraw, ImageFont
8
- from typing import List, Tuple, Dict, Optional
9
- import os
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class StickFigure:
15
- """
16
- Creates stick figure drawings for motivation/story videos.
17
-
18
- Features:
19
- - Simple black stick figures
20
- - Various poses (standing, walking, running, etc.)
21
- - Props (crown, money bag, bed, etc.)
22
- - Text overlays
23
- - White background (clean look)
24
- """
25
-
26
- # Video dimensions (9:16 portrait)
27
- WIDTH = 1080
28
- HEIGHT = 1920
29
-
30
- # Stick figure size
31
- HEAD_RADIUS = 40
32
- BODY_LENGTH = 120
33
- ARM_LENGTH = 80
34
- LEG_LENGTH = 100
35
- LINE_WIDTH = 8
36
-
37
- # Colors
38
- BG_COLOR = (255, 255, 255) # White
39
- FIGURE_COLOR = (0, 0, 0) # Black
40
-
41
- def __init__(self):
42
- self.frames = []
43
-
44
- def draw_stick_figure(
45
- self,
46
- draw: ImageDraw,
47
- x: int,
48
- y: int,
49
- pose: str = "standing",
50
- scale: float = 1.0,
51
- props: List[str] = None
52
- ):
53
- """
54
- Draw a stick figure at position (x, y).
55
-
56
- Args:
57
- draw: PIL ImageDraw object
58
- x, y: Center position for the figure
59
- pose: Pose type
60
- scale: Size multiplier
61
- props: List of props to add
62
- """
63
- props = props or []
64
-
65
- # Scale dimensions
66
- head_r = int(self.HEAD_RADIUS * scale)
67
- body_len = int(self.BODY_LENGTH * scale)
68
- arm_len = int(self.ARM_LENGTH * scale)
69
- leg_len = int(self.LEG_LENGTH * scale)
70
- line_w = max(4, int(self.LINE_WIDTH * scale))
71
-
72
- # Head position
73
- head_y = y - body_len - head_r
74
-
75
- # Draw based on pose
76
- if pose == "standing":
77
- self._draw_standing(draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w)
78
- elif pose == "walking":
79
- self._draw_walking(draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w)
80
- elif pose == "running":
81
- self._draw_running(draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w)
82
- elif pose == "sitting":
83
- self._draw_sitting(draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w)
84
- elif pose == "sleeping":
85
- self._draw_sleeping(draw, x, y, head_r, body_len, arm_len, leg_len, line_w)
86
- elif pose == "waving":
87
- self._draw_waving(draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w)
88
- elif pose == "thinking":
89
- self._draw_thinking(draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w)
90
- else:
91
- self._draw_standing(draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w)
92
-
93
- # Draw props
94
- for prop in props:
95
- self._draw_prop(draw, x, head_y, head_r, prop, scale)
96
-
97
- def _draw_standing(self, draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w):
98
- """Draw standing pose"""
99
- # Head
100
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
101
- outline=self.FIGURE_COLOR, width=line_w)
102
-
103
- # Body
104
- body_top = head_y + head_r
105
- body_bottom = body_top + body_len
106
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
107
-
108
- # Arms (slightly down)
109
- arm_y = body_top + body_len // 4
110
- draw.line([x, arm_y, x - arm_len, arm_y + arm_len // 2], fill=self.FIGURE_COLOR, width=line_w)
111
- draw.line([x, arm_y, x + arm_len, arm_y + arm_len // 2], fill=self.FIGURE_COLOR, width=line_w)
112
-
113
- # Legs
114
- draw.line([x, body_bottom, x - leg_len // 2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
115
- draw.line([x, body_bottom, x + leg_len // 2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
116
-
117
- def _draw_walking(self, draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w):
118
- """Draw walking pose"""
119
- # Head
120
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
121
- outline=self.FIGURE_COLOR, width=line_w)
122
-
123
- # Body (slightly leaning)
124
- body_top = head_y + head_r
125
- body_bottom = body_top + body_len
126
- draw.line([x, body_top, x + 10, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
127
-
128
- # Arms (swinging)
129
- arm_y = body_top + body_len // 4
130
- draw.line([x, arm_y, x - arm_len // 2, arm_y + arm_len], fill=self.FIGURE_COLOR, width=line_w)
131
- draw.line([x, arm_y, x + arm_len, arm_y - arm_len // 3], fill=self.FIGURE_COLOR, width=line_w)
132
-
133
- # Legs (one forward, one back)
134
- draw.line([x + 10, body_bottom, x - leg_len, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
135
- draw.line([x + 10, body_bottom, x + leg_len, body_bottom + leg_len // 2], fill=self.FIGURE_COLOR, width=line_w)
136
-
137
- def _draw_running(self, draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w):
138
- """Draw running pose"""
139
- # Head (leaning forward)
140
- draw.ellipse([x - head_r + 20, head_y - head_r, x + head_r + 20, head_y + head_r],
141
- outline=self.FIGURE_COLOR, width=line_w)
142
-
143
- # Body (leaning forward)
144
- body_top = head_y + head_r
145
- body_bottom = body_top + body_len
146
- draw.line([x + 20, body_top, x + 40, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
147
-
148
- # Arms (dynamic swing)
149
- arm_y = body_top + body_len // 4 + 20
150
- draw.line([x + 20, arm_y, x - arm_len, arm_y + arm_len // 3], fill=self.FIGURE_COLOR, width=line_w)
151
- draw.line([x + 20, arm_y, x + arm_len + 30, arm_y - arm_len // 2], fill=self.FIGURE_COLOR, width=line_w)
152
-
153
- # Legs (wide stride)
154
- draw.line([x + 40, body_bottom, x - leg_len - 20, body_bottom + leg_len // 2], fill=self.FIGURE_COLOR, width=line_w)
155
- draw.line([x + 40, body_bottom, x + leg_len + 40, body_bottom + leg_len // 3], fill=self.FIGURE_COLOR, width=line_w)
156
-
157
- def _draw_sitting(self, draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w):
158
- """Draw sitting pose"""
159
- head_y = head_y + leg_len # Lower position
160
-
161
- # Head
162
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
163
- outline=self.FIGURE_COLOR, width=line_w)
164
-
165
- # Body
166
- body_top = head_y + head_r
167
- body_bottom = body_top + body_len // 2
168
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
169
-
170
- # Arms (on lap)
171
- arm_y = body_top + body_len // 4
172
- draw.line([x, arm_y, x - arm_len // 2, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
173
- draw.line([x, arm_y, x + arm_len // 2, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
174
-
175
- # Legs (bent)
176
- draw.line([x, body_bottom, x - leg_len, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
177
- draw.line([x - leg_len, body_bottom, x - leg_len, body_bottom + leg_len // 2], fill=self.FIGURE_COLOR, width=line_w)
178
- draw.line([x, body_bottom, x + leg_len, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
179
- draw.line([x + leg_len, body_bottom, x + leg_len, body_bottom + leg_len // 2], fill=self.FIGURE_COLOR, width=line_w)
180
-
181
- def _draw_sleeping(self, draw, x, y, head_r, body_len, arm_len, leg_len, line_w):
182
- """Draw sleeping pose (horizontal)"""
183
- # Horizontal figure
184
- y = y - leg_len # Adjust position
185
-
186
- # Head
187
- draw.ellipse([x - body_len - head_r, y - head_r, x - body_len + head_r, y + head_r],
188
- outline=self.FIGURE_COLOR, width=line_w)
189
-
190
- # Body (horizontal)
191
- draw.line([x - body_len + head_r, y, x, y], fill=self.FIGURE_COLOR, width=line_w)
192
-
193
- # Arms (tucked)
194
- draw.line([x - body_len // 2, y, x - body_len // 2, y - arm_len // 3], fill=self.FIGURE_COLOR, width=line_w)
195
-
196
- # Legs (horizontal)
197
- draw.line([x, y, x + leg_len, y + 10], fill=self.FIGURE_COLOR, width=line_w)
198
- draw.line([x, y, x + leg_len, y - 10], fill=self.FIGURE_COLOR, width=line_w)
199
-
200
- # Zzz
201
- draw.text((x - body_len, y - head_r - 60), "Zzz", fill=self.FIGURE_COLOR)
202
-
203
- def _draw_waving(self, draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w):
204
- """Draw waving pose"""
205
- # Head
206
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
207
- outline=self.FIGURE_COLOR, width=line_w)
208
-
209
- # Body
210
- body_top = head_y + head_r
211
- body_bottom = body_top + body_len
212
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
213
-
214
- # Arms (one waving up)
215
- arm_y = body_top + body_len // 4
216
- draw.line([x, arm_y, x - arm_len, arm_y + arm_len // 2], fill=self.FIGURE_COLOR, width=line_w)
217
- draw.line([x, arm_y, x + arm_len // 2, arm_y - arm_len], fill=self.FIGURE_COLOR, width=line_w)
218
-
219
- # Legs
220
- draw.line([x, body_bottom, x - leg_len // 2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
221
- draw.line([x, body_bottom, x + leg_len // 2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
222
-
223
- def _draw_thinking(self, draw, x, head_y, head_r, body_len, arm_len, leg_len, line_w):
224
- """Draw thinking pose"""
225
- # Head
226
- draw.ellipse([x - head_r, head_y - head_r, x + head_r, head_y + head_r],
227
- outline=self.FIGURE_COLOR, width=line_w)
228
-
229
- # Body
230
- body_top = head_y + head_r
231
- body_bottom = body_top + body_len
232
- draw.line([x, body_top, x, body_bottom], fill=self.FIGURE_COLOR, width=line_w)
233
-
234
- # Arms (one on chin)
235
- arm_y = body_top + body_len // 4
236
- draw.line([x, arm_y, x - arm_len, arm_y + arm_len // 2], fill=self.FIGURE_COLOR, width=line_w)
237
- # Arm to chin
238
- draw.line([x, arm_y, x + arm_len // 3, arm_y - arm_len // 2], fill=self.FIGURE_COLOR, width=line_w)
239
- draw.line([x + arm_len // 3, arm_y - arm_len // 2, x + head_r // 2, head_y + head_r // 2],
240
- fill=self.FIGURE_COLOR, width=line_w)
241
-
242
- # Legs
243
- draw.line([x, body_bottom, x - leg_len // 2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
244
- draw.line([x, body_bottom, x + leg_len // 2, body_bottom + leg_len], fill=self.FIGURE_COLOR, width=line_w)
245
-
246
- # Thought bubble
247
- draw.ellipse([x + head_r + 20, head_y - head_r - 80, x + head_r + 120, head_y - head_r],
248
- outline=self.FIGURE_COLOR, width=3)
249
- draw.text((x + head_r + 45, head_y - head_r - 55), "?", fill=self.FIGURE_COLOR)
250
-
251
- def _draw_prop(self, draw, x, head_y, head_r, prop: str, scale: float):
252
- """Draw props on the figure"""
253
- if prop == "crown":
254
- # Crown on head
255
- crown_y = head_y - head_r - 10
256
- points = [
257
- (x - 30, crown_y),
258
- (x - 20, crown_y - 30),
259
- (x, crown_y - 10),
260
- (x + 20, crown_y - 30),
261
- (x + 30, crown_y)
262
- ]
263
- draw.polygon(points, fill=(255, 215, 0), outline=(0, 0, 0))
264
-
265
- elif prop == "money":
266
- # Money bag
267
- bag_x = x + 80
268
- bag_y = head_y + 100
269
- draw.ellipse([bag_x, bag_y, bag_x + 50, bag_y + 60], fill=(34, 139, 34), outline=(0, 0, 0))
270
- draw.text((bag_x + 15, bag_y + 15), "$", fill=(255, 255, 255))
271
-
272
- elif prop == "suit":
273
- # Simple tie
274
- tie_y = head_y + head_r + 10
275
- draw.polygon([
276
- (x, tie_y),
277
- (x - 15, tie_y + 20),
278
- (x, tie_y + 80),
279
- (x + 15, tie_y + 20)
280
- ], fill=(128, 0, 0))
281
-
282
- def add_text_overlay(
283
- self,
284
- img: Image.Image,
285
- text: str,
286
- position: str = "center",
287
- font_size: int = 80
288
- ) -> Image.Image:
289
- """Add text overlay to image"""
290
- draw = ImageDraw.Draw(img)
291
-
292
- try:
293
- font = ImageFont.truetype("arial.ttf", font_size)
294
- except:
295
- font = ImageFont.load_default()
296
-
297
- bbox = draw.textbbox((0, 0), text, font=font)
298
- text_width = bbox[2] - bbox[0]
299
- text_height = bbox[3] - bbox[1]
300
-
301
- if position == "center":
302
- pos = ((self.WIDTH - text_width) // 2, (self.HEIGHT - text_height) // 2)
303
- elif position == "top":
304
- pos = ((self.WIDTH - text_width) // 2, 100)
305
- elif position == "bottom":
306
- pos = ((self.WIDTH - text_width) // 2, self.HEIGHT - text_height - 100)
307
- else:
308
- pos = ((self.WIDTH - text_width) // 2, (self.HEIGHT - text_height) // 2)
309
-
310
- # Draw text with outline
311
- draw.text(pos, text, fill=self.FIGURE_COLOR, font=font,
312
- stroke_width=3, stroke_fill=(200, 200, 200))
313
-
314
- return img
315
-
316
- def create_scene(
317
- self,
318
- pose: str = "standing",
319
- props: List[str] = None,
320
- text: str = None,
321
- text_position: str = "bottom"
322
- ) -> Image.Image:
323
- """Create a single scene with stick figure"""
324
- # Create white background
325
- img = Image.new('RGB', (self.WIDTH, self.HEIGHT), self.BG_COLOR)
326
- draw = ImageDraw.Draw(img)
327
-
328
- # Draw figure in center
329
- self.draw_stick_figure(draw, self.WIDTH // 2, self.HEIGHT // 2 + 100,
330
- pose=pose, scale=1.5, props=props)
331
-
332
- # Add text if provided
333
- if text:
334
- self.add_text_overlay(img, text, position=text_position)
335
-
336
- return img
337
-
338
- def generate_motivation_frames(
339
- self,
340
- scenes: List[Dict],
341
- output_dir: str = "temp"
342
- ) -> List[str]:
343
- """
344
- Generate frames for motivation video.
345
-
346
- Args:
347
- scenes: List of scene dicts with pose, props, text, duration
348
- output_dir: Directory to save frames
349
-
350
- Returns:
351
- List of frame file paths
352
- """
353
- os.makedirs(output_dir, exist_ok=True)
354
- frame_paths = []
355
- frame_num = 0
356
-
357
- for scene in scenes:
358
- pose = scene.get("pose", "standing")
359
- props = scene.get("props", [])
360
- text = scene.get("text", None)
361
- duration = scene.get("duration", 1.0) # seconds
362
-
363
- # Create scene image
364
- img = self.create_scene(pose=pose, props=props, text=text)
365
-
366
- # Generate frames for duration (30 fps)
367
- num_frames = int(duration * 30)
368
- for _ in range(num_frames):
369
- frame_path = os.path.join(output_dir, f"frame_{frame_num:04d}.png")
370
- img.save(frame_path)
371
- frame_paths.append(frame_path)
372
- frame_num += 1
373
-
374
- logger.info(f"Generated {len(frame_paths)} frames for motivation video")
375
- return frame_paths
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modules/art_reels/services/video_composer.py DELETED
@@ -1,215 +0,0 @@
1
- """
2
- Video Composer - Combines frames into final video with captions
3
- Uses MoviePy for video rendering with word-by-word captions like Story Reels
4
- """
5
- import logging
6
- import os
7
- import uuid
8
- from pathlib import Path
9
- from typing import List, Optional, Dict
10
- from moviepy.editor import (
11
- ImageSequenceClip,
12
- AudioFileClip,
13
- CompositeAudioClip,
14
- CompositeVideoClip,
15
- TextClip
16
- )
17
-
18
- logger = logging.getLogger(__name__)
19
-
20
-
21
- class VideoComposer:
22
- """
23
- Combines image frames into final video with professional captions.
24
-
25
- Features:
26
- - Frame sequence to video
27
- - Audio overlay
28
- - Background music
29
- - Word-by-word captions (like Story Reels)
30
- """
31
-
32
- # Video settings
33
- FPS = 30
34
- TARGET_WIDTH = 1080
35
- TARGET_HEIGHT = 1920
36
-
37
- # Caption settings (matching Story Reels)
38
- CAPTION_FONT_SIZE = 72
39
- CAPTION_COLOR = 'white'
40
- CAPTION_STROKE_COLOR = 'black'
41
- CAPTION_STROKE_WIDTH = 4
42
- CAPTION_Y_POS = 0.75 # 75% down
43
-
44
- def __init__(self, output_dir: str = "videos"):
45
- self.output_dir = output_dir
46
- os.makedirs(output_dir, exist_ok=True)
47
-
48
- def _find_font(self) -> str:
49
- """Find a suitable font for captions"""
50
- # Try common font paths
51
- font_paths = [
52
- "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
53
- "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
54
- "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
55
- "C:/Windows/Fonts/arial.ttf",
56
- "C:/Windows/Fonts/arialbd.ttf",
57
- ]
58
-
59
- for path in font_paths:
60
- if os.path.exists(path):
61
- return path
62
-
63
- # Fallback
64
- return "DejaVu-Sans-Bold"
65
-
66
- def compose_video(
67
- self,
68
- frame_paths: List[str],
69
- audio_path: Optional[str] = None,
70
- music_path: Optional[str] = None,
71
- output_name: Optional[str] = None,
72
- fps: int = None,
73
- captions: Optional[List[Dict]] = None
74
- ) -> str:
75
- """
76
- Compose video from frames with optional captions.
77
-
78
- Args:
79
- frame_paths: List of frame image paths
80
- audio_path: Optional voice audio path
81
- music_path: Optional background music path
82
- output_name: Custom output filename
83
- fps: Frames per second
84
- captions: Optional list of captions [{text, startMs, endMs}]
85
-
86
- Returns:
87
- Path to output video
88
- """
89
- fps = fps or self.FPS
90
- output_name = output_name or f"art_video_{uuid.uuid4().hex[:8]}.mp4"
91
- output_path = os.path.join(self.output_dir, output_name)
92
-
93
- try:
94
- logger.info(f"Composing video from {len(frame_paths)} frames")
95
-
96
- # Create video clip from frames
97
- base_clip = ImageSequenceClip(frame_paths, fps=fps)
98
-
99
- # If captions provided, add them
100
- if captions:
101
- base_clip = self._add_captions(base_clip, captions)
102
-
103
- # Add audio if provided
104
- audio_clips = []
105
-
106
- if audio_path and os.path.exists(audio_path):
107
- voice_audio = AudioFileClip(audio_path)
108
- audio_clips.append(voice_audio)
109
-
110
- if music_path and os.path.exists(music_path):
111
- music_audio = AudioFileClip(music_path)
112
- # Loop music if needed
113
- if music_audio.duration < base_clip.duration:
114
- music_audio = music_audio.loop(duration=base_clip.duration)
115
- else:
116
- music_audio = music_audio.subclip(0, base_clip.duration)
117
- # Lower volume for background
118
- music_audio = music_audio.volumex(0.3)
119
- audio_clips.append(music_audio)
120
-
121
- if audio_clips:
122
- if len(audio_clips) > 1:
123
- final_audio = CompositeAudioClip(audio_clips)
124
- else:
125
- final_audio = audio_clips[0]
126
- base_clip = base_clip.set_audio(final_audio)
127
-
128
- # Write video
129
- base_clip.write_videofile(
130
- output_path,
131
- codec='libx264',
132
- audio_codec='aac',
133
- fps=fps,
134
- preset='medium',
135
- threads=4
136
- )
137
-
138
- # Cleanup
139
- base_clip.close()
140
- for ac in audio_clips:
141
- ac.close()
142
-
143
- logger.info(f"Video saved to: {output_path}")
144
- return output_path
145
-
146
- except Exception as e:
147
- logger.error(f"Error composing video: {e}")
148
- raise
149
-
150
- def _add_captions(
151
- self,
152
- video_clip,
153
- captions: List[Dict]
154
- ):
155
- """
156
- Add word-by-word captions to video.
157
-
158
- Args:
159
- video_clip: Base video clip
160
- captions: List of [{text, startMs, endMs}]
161
-
162
- Returns:
163
- CompositeVideoClip with captions
164
- """
165
- font_name = self._find_font()
166
- caption_clips = []
167
- y_pos = self.TARGET_HEIGHT * self.CAPTION_Y_POS
168
-
169
- for cap in captions:
170
- start_time = cap.get("startMs", 0) / 1000
171
- end_time = cap.get("endMs", 0) / 1000
172
- duration = end_time - start_time
173
- text = cap.get("text", "")
174
-
175
- if duration <= 0 or not text:
176
- continue
177
-
178
- try:
179
- txt_clip = TextClip(
180
- text,
181
- fontsize=self.CAPTION_FONT_SIZE,
182
- font=font_name,
183
- color=self.CAPTION_COLOR,
184
- stroke_color=self.CAPTION_STROKE_COLOR,
185
- stroke_width=self.CAPTION_STROKE_WIDTH,
186
- method='caption',
187
- size=(self.TARGET_WIDTH - 100, None),
188
- align='center'
189
- )
190
-
191
- # Position at bottom center
192
- txt_clip = txt_clip.set_position(('center', y_pos))
193
- txt_clip = txt_clip.set_start(start_time)
194
- txt_clip = txt_clip.set_duration(duration)
195
-
196
- caption_clips.append(txt_clip)
197
-
198
- except Exception as e:
199
- logger.warning(f"Failed to create caption: {e}")
200
- continue
201
-
202
- if caption_clips:
203
- logger.info(f"Added {len(caption_clips)} caption clips")
204
- return CompositeVideoClip([video_clip] + caption_clips)
205
-
206
- return video_clip
207
-
208
- def cleanup_frames(self, frame_paths: List[str]):
209
- """Delete temporary frame files"""
210
- for path in frame_paths:
211
- try:
212
- if os.path.exists(path):
213
- os.remove(path)
214
- except Exception as e:
215
- logger.warning(f"Failed to delete frame {path}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/index.html CHANGED
@@ -276,9 +276,6 @@
276
  <button class="tab-btn" data-tab="trends">
277
  📊 Trends
278
  </button>
279
- <button class="tab-btn" data-tab="art">
280
- 🎨 Art Reels
281
- </button>
282
  </div>
283
 
284
  <!-- Story Reels Tab -->
@@ -572,81 +569,6 @@
572
  </div>
573
  </div>
574
 
575
- <!-- Art Reels Tab -->
576
- <div id="art-tab" class="tab-content">
577
- <div class="card">
578
- <h2>🎨 Art Reels Generator</h2>
579
- <p style="color: var(--text-secondary); margin-bottom: 1.5rem;">
580
- Create animated art videos using pure Python - No AI API needed!
581
- </p>
582
-
583
- <!-- Minecraft Block Art -->
584
- <div style="margin-bottom: 2rem; padding: 1.5rem; background: var(--bg-secondary); border-radius: 12px;">
585
- <h3 style="margin-bottom: 1rem;">🏠 Minecraft Block Art</h3>
586
- <p style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 1rem;">
587
- Isometric block-by-block building animation
588
- </p>
589
- <form id="minecraftForm">
590
- <div class="form-group">
591
- <label>Description</label>
592
- <input type="text" id="mcDescription" value="wooden survival house"
593
- placeholder="e.g., wooden survival house">
594
- </div>
595
- <div class="form-row">
596
- <div class="form-group">
597
- <label>Speed</label>
598
- <select id="mcSpeed">
599
- <option value="0.5">Slow</option>
600
- <option value="1.0" selected>Normal</option>
601
- <option value="2.0">Fast</option>
602
- </select>
603
- </div>
604
- </div>
605
- <button type="submit" class="submit-btn">🏠 Generate Minecraft Video</button>
606
- </form>
607
- <div id="minecraftStatus" class="status hidden"></div>
608
- </div>
609
-
610
- <!-- AI Drawing Animation -->
611
- <div style="margin-bottom: 2rem; padding: 1.5rem; background: var(--bg-secondary); border-radius: 12px;">
612
- <h3 style="margin-bottom: 1rem;">✏️ AI Drawing Animation</h3>
613
- <p style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 1rem;">
614
- AI generates paths → Line-by-line animated drawing. Type anything!
615
- </p>
616
- <form id="drawingForm">
617
- <div class="form-group">
618
- <label>What to Draw (AI-Powered)</label>
619
- <input type="text" id="drawSubject"
620
- placeholder="e.g., sports car, human face, flower, sunset, rocket..." value="sports car">
621
- <small style="color: var(--text-secondary); display: block; margin-top: 0.5rem;">
622
- 💡 Try: car, face, tree, house, flower, star, heart, sun, or any custom prompt
623
- </small>
624
- </div>
625
- <button type="submit" class="submit-btn">✏️ Generate AI Drawing Video</button>
626
- </form>
627
- <div id="drawingStatus" class="status hidden"></div>
628
- </div>
629
-
630
- <!-- Stick Figure Motivation -->
631
- <div style="padding: 1.5rem; background: var(--bg-secondary); border-radius: 12px;">
632
- <h3 style="margin-bottom: 1rem;">🎭 Stick Figure Motivation</h3>
633
- <p style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 1rem;">
634
- White background + black stick figures + voice sync
635
- </p>
636
- <form id="stickForm">
637
- <div class="form-group">
638
- <label>Script (Keywords: king, rich, sleep, run, walk, think, etc.)</label>
639
- <textarea id="stickScript" rows="4"
640
- placeholder="A rich man woke up from sleep. He thought about what to do today. Get ready for success!"></textarea>
641
- </div>
642
- <button type="submit" class="submit-btn">🎭 Generate Stick Figure Video</button>
643
- </form>
644
- <div id="stickStatus" class="status hidden"></div>
645
- </div>
646
-
647
- </div>
648
- </div>
649
-
650
  <script>
651
  // Tab switching
652
  document.querySelectorAll('.tab-btn').forEach(btn => {
 
276
  <button class="tab-btn" data-tab="trends">
277
  📊 Trends
278
  </button>
 
 
 
279
  </div>
280
 
281
  <!-- Story Reels Tab -->
 
569
  </div>
570
  </div>
571
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  <script>
573
  // Tab switching
574
  document.querySelectorAll('.tab-btn').forEach(btn => {