Commit ·
5c1a3a8
1
Parent(s): 61b6896
Remove art_reels module completely - not working properly
Browse files- modules/art_reels/__init__.py +0 -40
- modules/art_reels/router.py +0 -536
- modules/art_reels/schemas.py +0 -84
- modules/art_reels/services/__init__.py +0 -1
- modules/art_reels/services/ai_drawing_animator.py +0 -535
- modules/art_reels/services/ai_stick_figure.py +0 -663
- modules/art_reels/services/block_art.py +0 -283
- modules/art_reels/services/drawing_animator.py +0 -303
- modules/art_reels/services/professional_stick_figure.py +0 -1086
- modules/art_reels/services/stick_figure.py +0 -375
- modules/art_reels/services/video_composer.py +0 -215
- static/index.html +0 -78
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 => {
|