Commit ·
10d1cee
1
Parent(s): 0e9e349
Add Art Reels module - Minecraft Block Art, Code Drawing, Stick Figure Motivation
Browse files- modules/art_reels/__init__.py +26 -0
- modules/art_reels/router.py +386 -0
- modules/art_reels/schemas.py +84 -0
- modules/art_reels/services/__init__.py +1 -0
- modules/art_reels/services/block_art.py +283 -0
- modules/art_reels/services/drawing_animator.py +303 -0
- modules/art_reels/services/stick_figure.py +375 -0
- modules/art_reels/services/video_composer.py +116 -0
- static/index.html +214 -0
modules/art_reels/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 11 |
+
def register(app, config: NCAkitConfig):
|
| 12 |
+
"""Register art_reels module with the app"""
|
| 13 |
+
try:
|
| 14 |
+
from .router import router
|
| 15 |
+
|
| 16 |
+
# Include router
|
| 17 |
+
app.include_router(router, prefix="/api/art", tags=["Art Reels"])
|
| 18 |
+
|
| 19 |
+
logger.info("Art Reels module registered successfully")
|
| 20 |
+
return True
|
| 21 |
+
|
| 22 |
+
except Exception as e:
|
| 23 |
+
logger.error(f"Failed to register art_reels module: {e}")
|
| 24 |
+
import traceback
|
| 25 |
+
logger.error(traceback.format_exc())
|
| 26 |
+
return False
|
modules/art_reels/router.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.stick_figure import StickFigure
|
| 24 |
+
from .services.video_composer import VideoComposer
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
router = APIRouter()
|
| 29 |
+
|
| 30 |
+
# In-memory job storage
|
| 31 |
+
jobs: Dict[str, Dict] = {}
|
| 32 |
+
|
| 33 |
+
# Initialize services
|
| 34 |
+
block_art = BlockArt()
|
| 35 |
+
drawing_animator = DrawingAnimator()
|
| 36 |
+
stick_figure = StickFigure()
|
| 37 |
+
video_composer = VideoComposer(output_dir="videos/art_reels")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def update_job(job_id: str, status: str, progress: int = 0,
|
| 41 |
+
video_url: str = None, error: str = None):
|
| 42 |
+
"""Update job status"""
|
| 43 |
+
if job_id in jobs:
|
| 44 |
+
jobs[job_id].update({
|
| 45 |
+
"status": status,
|
| 46 |
+
"progress": progress,
|
| 47 |
+
"video_url": video_url,
|
| 48 |
+
"error": error
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def generate_minecraft_video(job_id: str, description: str, blocks: int, speed: float):
|
| 53 |
+
"""Background task to generate Minecraft block art video"""
|
| 54 |
+
temp_dir = f"temp/art_{job_id}"
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
update_job(job_id, "processing", 10)
|
| 58 |
+
|
| 59 |
+
# Generate frames
|
| 60 |
+
logger.info(f"Generating Minecraft frames for job {job_id}")
|
| 61 |
+
frames_per_block = max(1, int(3 / speed))
|
| 62 |
+
frame_paths = block_art.generate_build_animation(
|
| 63 |
+
description=description,
|
| 64 |
+
output_dir=temp_dir,
|
| 65 |
+
blocks_per_frame=frames_per_block
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
update_job(job_id, "processing", 60)
|
| 69 |
+
|
| 70 |
+
# Compose video
|
| 71 |
+
logger.info(f"Composing video for job {job_id}")
|
| 72 |
+
video_path = video_composer.compose_video(
|
| 73 |
+
frame_paths=frame_paths,
|
| 74 |
+
output_name=f"minecraft_{job_id}.mp4",
|
| 75 |
+
fps=30
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
update_job(job_id, "processing", 90)
|
| 79 |
+
|
| 80 |
+
# Cleanup frames
|
| 81 |
+
video_composer.cleanup_frames(frame_paths)
|
| 82 |
+
if os.path.exists(temp_dir):
|
| 83 |
+
shutil.rmtree(temp_dir)
|
| 84 |
+
|
| 85 |
+
update_job(job_id, "ready", 100, video_url=f"/api/art/video/{job_id}")
|
| 86 |
+
logger.info(f"Minecraft video ready: {job_id}")
|
| 87 |
+
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"Error generating Minecraft video: {e}")
|
| 90 |
+
update_job(job_id, "failed", error=str(e))
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def generate_drawing_video(job_id: str, subject: str, style: str, colors: bool):
|
| 94 |
+
"""Background task to generate drawing animation video"""
|
| 95 |
+
temp_dir = f"temp/art_{job_id}"
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
update_job(job_id, "processing", 10)
|
| 99 |
+
|
| 100 |
+
# Generate frames
|
| 101 |
+
logger.info(f"Generating drawing frames for job {job_id}")
|
| 102 |
+
frame_paths = drawing_animator.generate_drawing_animation(
|
| 103 |
+
subject=subject,
|
| 104 |
+
output_dir=temp_dir,
|
| 105 |
+
with_colors=colors
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
update_job(job_id, "processing", 60)
|
| 109 |
+
|
| 110 |
+
# Compose video
|
| 111 |
+
logger.info(f"Composing video for job {job_id}")
|
| 112 |
+
video_path = video_composer.compose_video(
|
| 113 |
+
frame_paths=frame_paths,
|
| 114 |
+
output_name=f"drawing_{job_id}.mp4",
|
| 115 |
+
fps=30
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
update_job(job_id, "processing", 90)
|
| 119 |
+
|
| 120 |
+
# Cleanup
|
| 121 |
+
video_composer.cleanup_frames(frame_paths)
|
| 122 |
+
if os.path.exists(temp_dir):
|
| 123 |
+
shutil.rmtree(temp_dir)
|
| 124 |
+
|
| 125 |
+
update_job(job_id, "ready", 100, video_url=f"/api/art/video/{job_id}")
|
| 126 |
+
logger.info(f"Drawing video ready: {job_id}")
|
| 127 |
+
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"Error generating drawing video: {e}")
|
| 130 |
+
update_job(job_id, "failed", error=str(e))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
async def generate_stick_figure_video(job_id: str, script: str, voice: str):
|
| 134 |
+
"""Background task to generate stick figure motivation video"""
|
| 135 |
+
temp_dir = f"temp/art_{job_id}"
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
update_job(job_id, "processing", 10)
|
| 139 |
+
|
| 140 |
+
# Parse script into scenes (simple keyword matching)
|
| 141 |
+
scenes = parse_script_to_scenes(script)
|
| 142 |
+
|
| 143 |
+
update_job(job_id, "processing", 30)
|
| 144 |
+
|
| 145 |
+
# Generate frames
|
| 146 |
+
logger.info(f"Generating stick figure frames for job {job_id}")
|
| 147 |
+
frame_paths = stick_figure.generate_motivation_frames(
|
| 148 |
+
scenes=scenes,
|
| 149 |
+
output_dir=temp_dir
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
update_job(job_id, "processing", 60)
|
| 153 |
+
|
| 154 |
+
# Compose video
|
| 155 |
+
logger.info(f"Composing video for job {job_id}")
|
| 156 |
+
video_path = video_composer.compose_video(
|
| 157 |
+
frame_paths=frame_paths,
|
| 158 |
+
output_name=f"stick_{job_id}.mp4",
|
| 159 |
+
fps=30
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
update_job(job_id, "processing", 90)
|
| 163 |
+
|
| 164 |
+
# Cleanup
|
| 165 |
+
video_composer.cleanup_frames(frame_paths)
|
| 166 |
+
if os.path.exists(temp_dir):
|
| 167 |
+
shutil.rmtree(temp_dir)
|
| 168 |
+
|
| 169 |
+
update_job(job_id, "ready", 100, video_url=f"/api/art/video/{job_id}")
|
| 170 |
+
logger.info(f"Stick figure video ready: {job_id}")
|
| 171 |
+
|
| 172 |
+
except Exception as e:
|
| 173 |
+
logger.error(f"Error generating stick figure video: {e}")
|
| 174 |
+
update_job(job_id, "failed", error=str(e))
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def parse_script_to_scenes(script: str) -> list:
|
| 178 |
+
"""Parse script text into scenes with poses and keywords"""
|
| 179 |
+
scenes = []
|
| 180 |
+
|
| 181 |
+
# Split into sentences
|
| 182 |
+
sentences = [s.strip() for s in script.replace("।", ".").split(".") if s.strip()]
|
| 183 |
+
|
| 184 |
+
# Keyword to pose/prop mapping
|
| 185 |
+
keyword_mapping = {
|
| 186 |
+
# Poses
|
| 187 |
+
"ঘুম": ("sleeping", []),
|
| 188 |
+
"sleep": ("sleeping", []),
|
| 189 |
+
"দৌড়": ("running", []),
|
| 190 |
+
"run": ("running", []),
|
| 191 |
+
"হাঁট": ("walking", []),
|
| 192 |
+
"walk": ("walking", []),
|
| 193 |
+
"বস": ("sitting", []),
|
| 194 |
+
"sit": ("sitting", []),
|
| 195 |
+
"ভাব": ("thinking", []),
|
| 196 |
+
"think": ("thinking", []),
|
| 197 |
+
# Props
|
| 198 |
+
"রাজা": ("standing", ["crown"]),
|
| 199 |
+
"king": ("standing", ["crown"]),
|
| 200 |
+
"বড়লোক": ("standing", ["money", "suit"]),
|
| 201 |
+
"rich": ("standing", ["money", "suit"]),
|
| 202 |
+
"টাকা": ("standing", ["money"]),
|
| 203 |
+
"money": ("standing", ["money"]),
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
for sentence in sentences:
|
| 207 |
+
sentence_lower = sentence.lower()
|
| 208 |
+
pose = "standing"
|
| 209 |
+
props = []
|
| 210 |
+
|
| 211 |
+
# Check for keywords
|
| 212 |
+
for keyword, (detected_pose, detected_props) in keyword_mapping.items():
|
| 213 |
+
if keyword in sentence_lower:
|
| 214 |
+
pose = detected_pose
|
| 215 |
+
props.extend(detected_props)
|
| 216 |
+
break
|
| 217 |
+
|
| 218 |
+
# Check for text overlay triggers
|
| 219 |
+
text = None
|
| 220 |
+
text_triggers = ["get ready", "remember", "important", "key point", "মনে রাখ"]
|
| 221 |
+
for trigger in text_triggers:
|
| 222 |
+
if trigger in sentence_lower:
|
| 223 |
+
text = sentence[:50] + "..." if len(sentence) > 50 else sentence
|
| 224 |
+
|
| 225 |
+
scenes.append({
|
| 226 |
+
"pose": pose,
|
| 227 |
+
"props": list(set(props)),
|
| 228 |
+
"text": text,
|
| 229 |
+
"duration": max(1.5, len(sentence) / 20) # ~20 chars per second
|
| 230 |
+
})
|
| 231 |
+
|
| 232 |
+
return scenes
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# ===================
|
| 236 |
+
# API Endpoints
|
| 237 |
+
# ===================
|
| 238 |
+
|
| 239 |
+
@router.post("/minecraft",
|
| 240 |
+
response_model=JobResponse,
|
| 241 |
+
summary="Generate Minecraft block art video",
|
| 242 |
+
description="Create a Minecraft-style block-by-block building animation"
|
| 243 |
+
)
|
| 244 |
+
async def create_minecraft_video(request: MinecraftRequest, background_tasks: BackgroundTasks):
|
| 245 |
+
"""Generate Minecraft block art video"""
|
| 246 |
+
job_id = uuid.uuid4().hex[:12]
|
| 247 |
+
|
| 248 |
+
jobs[job_id] = {
|
| 249 |
+
"job_id": job_id,
|
| 250 |
+
"type": "minecraft",
|
| 251 |
+
"status": "queued",
|
| 252 |
+
"progress": 0,
|
| 253 |
+
"video_url": None,
|
| 254 |
+
"error": None
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
background_tasks.add_task(
|
| 258 |
+
generate_minecraft_video,
|
| 259 |
+
job_id,
|
| 260 |
+
request.description,
|
| 261 |
+
request.blocks,
|
| 262 |
+
request.speed
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
return JobResponse(
|
| 266 |
+
job_id=job_id,
|
| 267 |
+
status="queued",
|
| 268 |
+
message="Minecraft video generation started"
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
@router.post("/drawing",
|
| 273 |
+
response_model=JobResponse,
|
| 274 |
+
summary="Generate code drawing animation",
|
| 275 |
+
description="Create a line-by-line drawing animation video"
|
| 276 |
+
)
|
| 277 |
+
async def create_drawing_video(request: DrawingRequest, background_tasks: BackgroundTasks):
|
| 278 |
+
"""Generate code drawing animation video"""
|
| 279 |
+
job_id = uuid.uuid4().hex[:12]
|
| 280 |
+
|
| 281 |
+
jobs[job_id] = {
|
| 282 |
+
"job_id": job_id,
|
| 283 |
+
"type": "drawing",
|
| 284 |
+
"status": "queued",
|
| 285 |
+
"progress": 0,
|
| 286 |
+
"video_url": None,
|
| 287 |
+
"error": None
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
background_tasks.add_task(
|
| 291 |
+
generate_drawing_video,
|
| 292 |
+
job_id,
|
| 293 |
+
request.subject,
|
| 294 |
+
request.style.value,
|
| 295 |
+
request.colors
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
return JobResponse(
|
| 299 |
+
job_id=job_id,
|
| 300 |
+
status="queued",
|
| 301 |
+
message="Drawing video generation started"
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
@router.post("/stick-figure",
|
| 306 |
+
response_model=JobResponse,
|
| 307 |
+
summary="Generate stick figure motivation video",
|
| 308 |
+
description="Create a stick figure motivation/story video"
|
| 309 |
+
)
|
| 310 |
+
async def create_stick_figure_video(request: StickFigureRequest, background_tasks: BackgroundTasks):
|
| 311 |
+
"""Generate stick figure motivation video"""
|
| 312 |
+
job_id = uuid.uuid4().hex[:12]
|
| 313 |
+
|
| 314 |
+
jobs[job_id] = {
|
| 315 |
+
"job_id": job_id,
|
| 316 |
+
"type": "stick_figure",
|
| 317 |
+
"status": "queued",
|
| 318 |
+
"progress": 0,
|
| 319 |
+
"video_url": None,
|
| 320 |
+
"error": None
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
background_tasks.add_task(
|
| 324 |
+
generate_stick_figure_video,
|
| 325 |
+
job_id,
|
| 326 |
+
request.script,
|
| 327 |
+
request.voice
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
return JobResponse(
|
| 331 |
+
job_id=job_id,
|
| 332 |
+
status="queued",
|
| 333 |
+
message="Stick figure video generation started"
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
@router.get("/{job_id}/status",
|
| 338 |
+
response_model=JobStatus,
|
| 339 |
+
summary="Get job status",
|
| 340 |
+
description="Check the status of a video generation job"
|
| 341 |
+
)
|
| 342 |
+
async def get_job_status(job_id: str):
|
| 343 |
+
"""Get video generation job status"""
|
| 344 |
+
if job_id not in jobs:
|
| 345 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 346 |
+
|
| 347 |
+
job = jobs[job_id]
|
| 348 |
+
return JobStatus(**job)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
@router.get("/video/{job_id}",
|
| 352 |
+
summary="Download video",
|
| 353 |
+
description="Download the generated video file"
|
| 354 |
+
)
|
| 355 |
+
async def download_video(job_id: str):
|
| 356 |
+
"""Download generated video"""
|
| 357 |
+
if job_id not in jobs:
|
| 358 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 359 |
+
|
| 360 |
+
job = jobs[job_id]
|
| 361 |
+
if job["status"] != "ready":
|
| 362 |
+
raise HTTPException(status_code=400, detail=f"Video not ready. Status: {job['status']}")
|
| 363 |
+
|
| 364 |
+
# Find video file
|
| 365 |
+
video_dir = "videos/art_reels"
|
| 366 |
+
for filename in os.listdir(video_dir):
|
| 367 |
+
if job_id in filename:
|
| 368 |
+
video_path = os.path.join(video_dir, filename)
|
| 369 |
+
return FileResponse(
|
| 370 |
+
video_path,
|
| 371 |
+
media_type="video/mp4",
|
| 372 |
+
filename=filename
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
raise HTTPException(status_code=404, detail="Video file not found")
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
@router.get("/templates",
|
| 379 |
+
summary="List drawing templates",
|
| 380 |
+
description="Get available drawing templates"
|
| 381 |
+
)
|
| 382 |
+
async def list_templates():
|
| 383 |
+
"""List available drawing templates"""
|
| 384 |
+
return {
|
| 385 |
+
"templates": list(DrawingAnimator.TEMPLATES.keys())
|
| 386 |
+
}
|
modules/art_reels/schemas.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Services init
|
modules/art_reels/services/block_art.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "✓ 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
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "✓ 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/stick_figure.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video Composer - Combines frames into final video
|
| 3 |
+
Uses MoviePy for video rendering
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import uuid
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
from moviepy.editor import ImageSequenceClip, AudioFileClip, CompositeAudioClip
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class VideoComposer:
|
| 15 |
+
"""
|
| 16 |
+
Combines image frames into final video.
|
| 17 |
+
|
| 18 |
+
Features:
|
| 19 |
+
- Frame sequence to video
|
| 20 |
+
- Audio overlay
|
| 21 |
+
- Background music
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
# Video settings
|
| 25 |
+
FPS = 30
|
| 26 |
+
|
| 27 |
+
def __init__(self, output_dir: str = "videos"):
|
| 28 |
+
self.output_dir = output_dir
|
| 29 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 30 |
+
|
| 31 |
+
def compose_video(
|
| 32 |
+
self,
|
| 33 |
+
frame_paths: List[str],
|
| 34 |
+
audio_path: Optional[str] = None,
|
| 35 |
+
music_path: Optional[str] = None,
|
| 36 |
+
output_name: Optional[str] = None,
|
| 37 |
+
fps: int = None
|
| 38 |
+
) -> str:
|
| 39 |
+
"""
|
| 40 |
+
Compose video from frames.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
frame_paths: List of frame image paths
|
| 44 |
+
audio_path: Optional voice audio path
|
| 45 |
+
music_path: Optional background music path
|
| 46 |
+
output_name: Custom output filename
|
| 47 |
+
fps: Frames per second
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
Path to output video
|
| 51 |
+
"""
|
| 52 |
+
fps = fps or self.FPS
|
| 53 |
+
output_name = output_name or f"art_video_{uuid.uuid4().hex[:8]}.mp4"
|
| 54 |
+
output_path = os.path.join(self.output_dir, output_name)
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
logger.info(f"Composing video from {len(frame_paths)} frames")
|
| 58 |
+
|
| 59 |
+
# Create video clip from frames
|
| 60 |
+
clip = ImageSequenceClip(frame_paths, fps=fps)
|
| 61 |
+
|
| 62 |
+
# Add audio if provided
|
| 63 |
+
audio_clips = []
|
| 64 |
+
|
| 65 |
+
if audio_path and os.path.exists(audio_path):
|
| 66 |
+
voice_audio = AudioFileClip(audio_path)
|
| 67 |
+
audio_clips.append(voice_audio)
|
| 68 |
+
|
| 69 |
+
if music_path and os.path.exists(music_path):
|
| 70 |
+
music_audio = AudioFileClip(music_path)
|
| 71 |
+
# Loop music if needed
|
| 72 |
+
if music_audio.duration < clip.duration:
|
| 73 |
+
music_audio = music_audio.loop(duration=clip.duration)
|
| 74 |
+
else:
|
| 75 |
+
music_audio = music_audio.subclip(0, clip.duration)
|
| 76 |
+
# Lower volume for background
|
| 77 |
+
music_audio = music_audio.volumex(0.3)
|
| 78 |
+
audio_clips.append(music_audio)
|
| 79 |
+
|
| 80 |
+
if audio_clips:
|
| 81 |
+
if len(audio_clips) > 1:
|
| 82 |
+
final_audio = CompositeAudioClip(audio_clips)
|
| 83 |
+
else:
|
| 84 |
+
final_audio = audio_clips[0]
|
| 85 |
+
clip = clip.set_audio(final_audio)
|
| 86 |
+
|
| 87 |
+
# Write video
|
| 88 |
+
clip.write_videofile(
|
| 89 |
+
output_path,
|
| 90 |
+
codec='libx264',
|
| 91 |
+
audio_codec='aac',
|
| 92 |
+
fps=fps,
|
| 93 |
+
preset='medium',
|
| 94 |
+
threads=4
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Cleanup
|
| 98 |
+
clip.close()
|
| 99 |
+
for ac in audio_clips:
|
| 100 |
+
ac.close()
|
| 101 |
+
|
| 102 |
+
logger.info(f"Video saved to: {output_path}")
|
| 103 |
+
return output_path
|
| 104 |
+
|
| 105 |
+
except Exception as e:
|
| 106 |
+
logger.error(f"Error composing video: {e}")
|
| 107 |
+
raise
|
| 108 |
+
|
| 109 |
+
def cleanup_frames(self, frame_paths: List[str]):
|
| 110 |
+
"""Delete temporary frame files"""
|
| 111 |
+
for path in frame_paths:
|
| 112 |
+
try:
|
| 113 |
+
if os.path.exists(path):
|
| 114 |
+
os.remove(path)
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.warning(f"Failed to delete frame {path}: {e}")
|
static/index.html
CHANGED
|
@@ -276,6 +276,9 @@
|
|
| 276 |
<button class="tab-btn" data-tab="trends">
|
| 277 |
📊 Trends
|
| 278 |
</button>
|
|
|
|
|
|
|
|
|
|
| 279 |
</div>
|
| 280 |
|
| 281 |
<!-- Story Reels Tab -->
|
|
@@ -569,6 +572,91 @@
|
|
| 569 |
</div>
|
| 570 |
</div>
|
| 571 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
<script>
|
| 573 |
// Tab switching
|
| 574 |
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
@@ -882,6 +970,132 @@
|
|
| 882 |
}
|
| 883 |
});
|
| 884 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 885 |
// ==========================================
|
| 886 |
// GEMINI CHATBOT TEST
|
| 887 |
// ==========================================
|
|
|
|
| 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 |
</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 |
+
<!-- Code Drawing Animation -->
|
| 611 |
+
<div style="margin-bottom: 2rem; padding: 1.5rem; background: var(--bg-secondary); border-radius: 12px;">
|
| 612 |
+
<h3 style="margin-bottom: 1rem;">✏️ Code Drawing Animation</h3>
|
| 613 |
+
<p style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 1rem;">
|
| 614 |
+
Line-by-line drawing with color fill
|
| 615 |
+
</p>
|
| 616 |
+
<form id="drawingForm">
|
| 617 |
+
<div class="form-group">
|
| 618 |
+
<label>What to Draw</label>
|
| 619 |
+
<select id="drawSubject">
|
| 620 |
+
<option value="house">House</option>
|
| 621 |
+
<option value="tree">Tree</option>
|
| 622 |
+
<option value="star">Star</option>
|
| 623 |
+
<option value="heart">Heart</option>
|
| 624 |
+
</select>
|
| 625 |
+
</div>
|
| 626 |
+
<div class="form-row">
|
| 627 |
+
<div class="form-group">
|
| 628 |
+
<label>Style</label>
|
| 629 |
+
<select id="drawStyle">
|
| 630 |
+
<option value="outline_first">Outline First</option>
|
| 631 |
+
<option value="color_fill">With Colors</option>
|
| 632 |
+
</select>
|
| 633 |
+
</div>
|
| 634 |
+
</div>
|
| 635 |
+
<button type="submit" class="submit-btn">✏️ Generate Drawing Video</button>
|
| 636 |
+
</form>
|
| 637 |
+
<div id="drawingStatus" class="status hidden"></div>
|
| 638 |
+
</div>
|
| 639 |
+
|
| 640 |
+
<!-- Stick Figure Motivation -->
|
| 641 |
+
<div style="padding: 1.5rem; background: var(--bg-secondary); border-radius: 12px;">
|
| 642 |
+
<h3 style="margin-bottom: 1rem;">🎭 Stick Figure Motivation</h3>
|
| 643 |
+
<p style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 1rem;">
|
| 644 |
+
White background + black stick figures + voice sync
|
| 645 |
+
</p>
|
| 646 |
+
<form id="stickForm">
|
| 647 |
+
<div class="form-group">
|
| 648 |
+
<label>Script (Keywords: রাজা, বড়লোক, ঘুম, দৌড়, etc.)</label>
|
| 649 |
+
<textarea id="stickScript" rows="4"
|
| 650 |
+
placeholder="একজন বড়লোক মানুষ সকালে ঘুম থেকে উঠল। সে মনে মনে ভাবল আজকে কি করব। Get ready for success!"></textarea>
|
| 651 |
+
</div>
|
| 652 |
+
<button type="submit" class="submit-btn">🎭 Generate Stick Figure Video</button>
|
| 653 |
+
</form>
|
| 654 |
+
<div id="stickStatus" class="status hidden"></div>
|
| 655 |
+
</div>
|
| 656 |
+
|
| 657 |
+
</div>
|
| 658 |
+
</div>
|
| 659 |
+
|
| 660 |
<script>
|
| 661 |
// Tab switching
|
| 662 |
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
|
|
| 970 |
}
|
| 971 |
});
|
| 972 |
|
| 973 |
+
// ==========================================
|
| 974 |
+
// ART REELS MODULE
|
| 975 |
+
// ==========================================
|
| 976 |
+
|
| 977 |
+
// Helper function for polling art job status
|
| 978 |
+
async function pollArtStatus(jobId, statusDiv, type) {
|
| 979 |
+
const checkStatus = async () => {
|
| 980 |
+
try {
|
| 981 |
+
const res = await fetch(`/api/art/${jobId}/status`);
|
| 982 |
+
const status = await res.json();
|
| 983 |
+
|
| 984 |
+
if (status.status === 'ready') {
|
| 985 |
+
statusDiv.className = 'status success';
|
| 986 |
+
statusDiv.innerHTML = `✅ Video Ready! <a href="${status.video_url}" target="_blank" style="color: var(--accent);">Download Video</a>`;
|
| 987 |
+
return;
|
| 988 |
+
} else if (status.status === 'failed') {
|
| 989 |
+
statusDiv.className = 'status error';
|
| 990 |
+
statusDiv.innerHTML = `❌ Failed: ${status.error}`;
|
| 991 |
+
return;
|
| 992 |
+
} else {
|
| 993 |
+
statusDiv.innerHTML = `⏳ ${status.status}... ${status.progress}%`;
|
| 994 |
+
setTimeout(checkStatus, 2000);
|
| 995 |
+
}
|
| 996 |
+
} catch (err) {
|
| 997 |
+
statusDiv.className = 'status error';
|
| 998 |
+
statusDiv.innerHTML = `❌ Error: ${err.message}`;
|
| 999 |
+
}
|
| 1000 |
+
};
|
| 1001 |
+
checkStatus();
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
// Minecraft Form
|
| 1005 |
+
document.getElementById('minecraftForm').addEventListener('submit', async (e) => {
|
| 1006 |
+
e.preventDefault();
|
| 1007 |
+
const status = document.getElementById('minecraftStatus');
|
| 1008 |
+
status.className = 'status';
|
| 1009 |
+
status.classList.remove('hidden');
|
| 1010 |
+
status.innerHTML = '⏳ Starting Minecraft video generation...';
|
| 1011 |
+
|
| 1012 |
+
const data = {
|
| 1013 |
+
description: document.getElementById('mcDescription').value,
|
| 1014 |
+
blocks: 50,
|
| 1015 |
+
speed: parseFloat(document.getElementById('mcSpeed').value)
|
| 1016 |
+
};
|
| 1017 |
+
|
| 1018 |
+
try {
|
| 1019 |
+
const res = await fetch('/api/art/minecraft', {
|
| 1020 |
+
method: 'POST',
|
| 1021 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1022 |
+
body: JSON.stringify(data)
|
| 1023 |
+
});
|
| 1024 |
+
const result = await res.json();
|
| 1025 |
+
|
| 1026 |
+
if (result.job_id) {
|
| 1027 |
+
status.innerHTML = `⏳ Job started: ${result.job_id}`;
|
| 1028 |
+
pollArtStatus(result.job_id, status, 'minecraft');
|
| 1029 |
+
}
|
| 1030 |
+
} catch (err) {
|
| 1031 |
+
status.className = 'status error';
|
| 1032 |
+
status.innerHTML = '❌ Error: ' + err.message;
|
| 1033 |
+
}
|
| 1034 |
+
});
|
| 1035 |
+
|
| 1036 |
+
// Drawing Form
|
| 1037 |
+
document.getElementById('drawingForm').addEventListener('submit', async (e) => {
|
| 1038 |
+
e.preventDefault();
|
| 1039 |
+
const status = document.getElementById('drawingStatus');
|
| 1040 |
+
status.className = 'status';
|
| 1041 |
+
status.classList.remove('hidden');
|
| 1042 |
+
status.innerHTML = '⏳ Starting drawing animation...';
|
| 1043 |
+
|
| 1044 |
+
const data = {
|
| 1045 |
+
subject: document.getElementById('drawSubject').value,
|
| 1046 |
+
style: document.getElementById('drawStyle').value,
|
| 1047 |
+
colors: true
|
| 1048 |
+
};
|
| 1049 |
+
|
| 1050 |
+
try {
|
| 1051 |
+
const res = await fetch('/api/art/drawing', {
|
| 1052 |
+
method: 'POST',
|
| 1053 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1054 |
+
body: JSON.stringify(data)
|
| 1055 |
+
});
|
| 1056 |
+
const result = await res.json();
|
| 1057 |
+
|
| 1058 |
+
if (result.job_id) {
|
| 1059 |
+
status.innerHTML = `⏳ Job started: ${result.job_id}`;
|
| 1060 |
+
pollArtStatus(result.job_id, status, 'drawing');
|
| 1061 |
+
}
|
| 1062 |
+
} catch (err) {
|
| 1063 |
+
status.className = 'status error';
|
| 1064 |
+
status.innerHTML = '❌ Error: ' + err.message;
|
| 1065 |
+
}
|
| 1066 |
+
});
|
| 1067 |
+
|
| 1068 |
+
// Stick Figure Form
|
| 1069 |
+
document.getElementById('stickForm').addEventListener('submit', async (e) => {
|
| 1070 |
+
e.preventDefault();
|
| 1071 |
+
const status = document.getElementById('stickStatus');
|
| 1072 |
+
status.className = 'status';
|
| 1073 |
+
status.classList.remove('hidden');
|
| 1074 |
+
status.innerHTML = '⏳ Starting stick figure video...';
|
| 1075 |
+
|
| 1076 |
+
const data = {
|
| 1077 |
+
script: document.getElementById('stickScript').value,
|
| 1078 |
+
voice: 'af_heart'
|
| 1079 |
+
};
|
| 1080 |
+
|
| 1081 |
+
try {
|
| 1082 |
+
const res = await fetch('/api/art/stick-figure', {
|
| 1083 |
+
method: 'POST',
|
| 1084 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1085 |
+
body: JSON.stringify(data)
|
| 1086 |
+
});
|
| 1087 |
+
const result = await res.json();
|
| 1088 |
+
|
| 1089 |
+
if (result.job_id) {
|
| 1090 |
+
status.innerHTML = `⏳ Job started: ${result.job_id}`;
|
| 1091 |
+
pollArtStatus(result.job_id, status, 'stick');
|
| 1092 |
+
}
|
| 1093 |
+
} catch (err) {
|
| 1094 |
+
status.className = 'status error';
|
| 1095 |
+
status.innerHTML = '❌ Error: ' + err.message;
|
| 1096 |
+
}
|
| 1097 |
+
});
|
| 1098 |
+
|
| 1099 |
// ==========================================
|
| 1100 |
// GEMINI CHATBOT TEST
|
| 1101 |
// ==========================================
|