WIP: AI Podcast Maker and YouTube Creator Studio integration

This commit is contained in:
ajaysi
2025-12-10 09:37:55 +05:30
parent 31f078c763
commit 81590cf4db
75 changed files with 11879 additions and 1380 deletions

View File

@@ -3,7 +3,7 @@ Content Assets API Router
API endpoints for managing unified content assets across all modules.
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Body
from sqlalchemy.orm import Session
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
@@ -118,6 +118,79 @@ async def get_assets(
raise HTTPException(status_code=500, detail=f"Error fetching assets: {str(e)}")
class AssetCreateRequest(BaseModel):
"""Request model for creating a new asset."""
asset_type: str = Field(..., description="Asset type: text, image, video, or audio")
source_module: str = Field(..., description="Source module that generated the asset")
filename: str = Field(..., description="Original filename")
file_url: str = Field(..., description="Public URL to access the asset")
file_path: Optional[str] = Field(None, description="Server file path (optional)")
file_size: Optional[int] = Field(None, description="File size in bytes")
mime_type: Optional[str] = Field(None, description="MIME type")
title: Optional[str] = Field(None, description="Asset title")
description: Optional[str] = Field(None, description="Asset description")
prompt: Optional[str] = Field(None, description="Generation prompt")
tags: Optional[List[str]] = Field(default_factory=list, description="List of tags")
asset_metadata: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional metadata")
provider: Optional[str] = Field(None, description="AI provider used")
model: Optional[str] = Field(None, description="Model used")
cost: Optional[float] = Field(0.0, description="Generation cost")
generation_time: Optional[float] = Field(None, description="Generation time in seconds")
@router.post("/", response_model=AssetResponse)
async def create_asset(
asset_data: AssetCreateRequest,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Create a new content asset."""
try:
user_id = current_user.get("user_id") or current_user.get("id")
if not user_id:
raise HTTPException(status_code=401, detail="User ID not found")
# Validate asset type
try:
asset_type_enum = AssetType(asset_data.asset_type.lower())
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid asset type: {asset_data.asset_type}")
# Validate source module
try:
source_module_enum = AssetSource(asset_data.source_module.lower())
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid source module: {asset_data.source_module}")
service = ContentAssetService(db)
asset = service.create_asset(
user_id=user_id,
asset_type=asset_type_enum,
source_module=source_module_enum,
filename=asset_data.filename,
file_url=asset_data.file_url,
file_path=asset_data.file_path,
file_size=asset_data.file_size,
mime_type=asset_data.mime_type,
title=asset_data.title,
description=asset_data.description,
prompt=asset_data.prompt,
tags=asset_data.tags or [],
asset_metadata=asset_data.asset_metadata or {},
provider=asset_data.provider,
model=asset_data.model,
cost=asset_data.cost,
generation_time=asset_data.generation_time,
)
return AssetResponse.model_validate(asset)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error creating asset: {str(e)}")
@router.post("/{asset_id}/favorite", response_model=Dict[str, Any])
async def toggle_favorite(
asset_id: int,

View File

@@ -40,22 +40,16 @@ from ...utils.constants import ERROR_MESSAGES, SUCCESS_MESSAGES
# Removed old service import - using orchestrator only
from ...services.calendar_generation_service import CalendarGenerationService
# Import for preflight checks
from services.subscription.preflight_validator import validate_calendar_generation_operations
from services.subscription.pricing_service import PricingService
from models.onboarding import OnboardingSession
from models.content_planning import ContentStrategy
# Create router
router = APIRouter(prefix="/calendar-generation", tags=["calendar-generation"])
# Helper function to convert Clerk user ID to integer
def get_user_id_int(clerk_user_id: str) -> int:
"""
Convert Clerk user ID string to integer for database compatibility.
Uses consistent hashing to ensure same user always gets same ID.
"""
try:
# Try to extract numeric portion from Clerk ID format (user_XXXX)
numeric_part = clerk_user_id.replace('user_', '').replace('-', '')[:8]
return int(numeric_part, 16) % 2147483647
except:
# Fallback to hash if extraction fails
return hash(clerk_user_id) % 2147483647
# Helper function removed - using Clerk ID string directly
@router.post("/generate-calendar", response_model=CalendarGenerationResponse)
async def generate_comprehensive_calendar(
@@ -71,15 +65,36 @@ async def generate_comprehensive_calendar(
try:
# Use authenticated user ID instead of request user ID for security
clerk_user_id = str(current_user.get('id'))
user_id_int = get_user_id_int(clerk_user_id)
logger.info(f"🎯 Generating comprehensive calendar for authenticated user {clerk_user_id} (int: {user_id_int})")
logger.info(f"🎯 Generating comprehensive calendar for authenticated user {clerk_user_id}")
# Preflight Checks
# 1. Check Onboarding Data
onboarding = db.query(OnboardingSession).filter(OnboardingSession.user_id == clerk_user_id).first()
if not onboarding:
raise HTTPException(status_code=400, detail="Onboarding data not found. Please complete onboarding first.")
# 2. Check Strategy (if provided)
if request.strategy_id:
# Assuming migration to string user_id
# Note: If migration hasn't run for ContentStrategy, this might fail if user_id column is Integer.
# But we are proceeding with the assumption of full string ID support.
strategy = db.query(ContentStrategy).filter(ContentStrategy.id == request.strategy_id).first()
if not strategy:
raise HTTPException(status_code=404, detail="Content Strategy not found.")
# Verify ownership
if str(strategy.user_id) != clerk_user_id:
raise HTTPException(status_code=403, detail="Not authorized to access this strategy.")
# 3. Subscription/Limits Check
pricing_service = PricingService(db)
validate_calendar_generation_operations(pricing_service, clerk_user_id)
# Initialize service with database session for active strategy access
calendar_service = CalendarGenerationService(db)
calendar_data = await calendar_service.generate_comprehensive_calendar(
user_id=user_id_int, # Use authenticated user ID
user_id=clerk_user_id, # Use authenticated user ID string
strategy_id=request.strategy_id,
calendar_type=request.calendar_type,
industry=request.industry,
@@ -222,15 +237,14 @@ async def get_trending_topics(
try:
# Use authenticated user ID instead of query parameter for security
clerk_user_id = str(current_user.get('id'))
user_id = get_user_id_int(clerk_user_id)
logger.info(f"📈 Getting trending topics for authenticated user {clerk_user_id} (int: {user_id}) in {industry}")
logger.info(f"📈 Getting trending topics for authenticated user {clerk_user_id} in {industry}")
# Initialize service with database session for active strategy access
calendar_service = CalendarGenerationService(db)
result = await calendar_service.get_trending_topics(
user_id=user_id,
user_id=clerk_user_id,
industry=industry,
limit=limit
)
@@ -257,9 +271,8 @@ async def get_comprehensive_user_data(
try:
# Use authenticated user ID instead of query parameter for security
clerk_user_id = str(current_user.get('id'))
user_id = get_user_id_int(clerk_user_id)
logger.info(f"Getting comprehensive user data for authenticated user {clerk_user_id} (int: {user_id}, force_refresh={force_refresh})")
logger.info(f"Getting comprehensive user data for authenticated user {clerk_user_id} (force_refresh={force_refresh})")
# Initialize cache service
from services.comprehensive_user_data_cache_service import ComprehensiveUserDataCacheService
@@ -267,7 +280,7 @@ async def get_comprehensive_user_data(
# Get data with caching
data, is_cached = await cache_service.get_cached_data(
user_id, None, force_refresh=force_refresh
clerk_user_id, None, force_refresh=force_refresh
)
if not data:
@@ -285,11 +298,11 @@ async def get_comprehensive_user_data(
"message": f"Comprehensive user data retrieved successfully (cache: {'HIT' if is_cached else 'MISS'})"
}
logger.info(f"Successfully retrieved comprehensive user data for user_id: {user_id} (cache: {'HIT' if is_cached else 'MISS'})")
logger.info(f"Successfully retrieved comprehensive user data for user_id: {clerk_user_id} (cache: {'HIT' if is_cached else 'MISS'})")
return result
except Exception as e:
logger.error(f"Error getting comprehensive user data for user_id {user_id}: {str(e)}")
logger.error(f"Error getting comprehensive user data for user_id {clerk_user_id}: {str(e)}")
logger.error(f"Exception type: {type(e)}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
@@ -373,18 +386,17 @@ async def start_calendar_generation(
try:
# Use authenticated user ID instead of request user ID for security
clerk_user_id = str(current_user.get('id'))
user_id_int = get_user_id_int(clerk_user_id)
logger.info(f"🎯 Starting calendar generation for authenticated user {clerk_user_id} (int: {user_id_int})")
logger.info(f"🎯 Starting calendar generation for authenticated user {clerk_user_id}")
# Initialize service with database session for active strategy access
calendar_service = CalendarGenerationService(db)
# Check if user already has an active session
existing_session = calendar_service._get_active_session_for_user(user_id_int)
existing_session = calendar_service._get_active_session_for_user(clerk_user_id)
if existing_session:
logger.info(f"🔄 User {user_id_int} already has active session: {existing_session}")
logger.info(f"🔄 User {clerk_user_id} already has active session: {existing_session}")
return {
"session_id": existing_session,
"status": "existing",
@@ -397,7 +409,7 @@ async def start_calendar_generation(
# Update request data with authenticated user ID
request_dict = request.dict()
request_dict['user_id'] = user_id_int # Override with authenticated user ID
request_dict['user_id'] = clerk_user_id # Override with authenticated user ID
# Initialize orchestrator session
success = calendar_service.initialize_orchestrator_session(session_id, request_dict)
@@ -464,7 +476,7 @@ async def get_cache_stats(db: Session = Depends(get_db)) -> Dict[str, Any]:
@router.delete("/cache/invalidate/{user_id}")
async def invalidate_user_cache(
user_id: int,
user_id: str,
strategy_id: Optional[int] = Query(None, description="Strategy ID to invalidate (optional)"),
db: Session = Depends(get_db)
) -> Dict[str, Any]:

View File

@@ -26,6 +26,10 @@ from ..utils.error_handlers import ContentPlanningErrorHandler
from ..utils.response_builders import ResponseBuilder
from ..utils.constants import ERROR_MESSAGES, SUCCESS_MESSAGES
# Import models for persistence
from models.enhanced_calendar_models import CalendarGenerationSession
from models.content_planning import CalendarEvent, ContentStrategy
class CalendarGenerationService:
"""Service class for calendar generation operations."""
@@ -42,7 +46,7 @@ class CalendarGenerationService:
logger.error(f"❌ Failed to initialize orchestrator: {e}")
self.orchestrator = None
async def generate_comprehensive_calendar(self, user_id: int, strategy_id: Optional[int] = None,
async def generate_comprehensive_calendar(self, user_id: str, strategy_id: Optional[int] = None,
calendar_type: str = "monthly", industry: Optional[str] = None,
business_size: str = "sme") -> Dict[str, Any]:
"""Generate a comprehensive AI-powered content calendar using the 12-step orchestrator."""
@@ -79,6 +83,10 @@ class CalendarGenerationService:
if progress and progress.get("status") == "completed":
calendar_data = progress.get("step_results", {}).get("step_12", {}).get("result", {})
processing_time = time.time() - start_time
# Save to database
await self._save_calendar_to_db(user_id, strategy_id, calendar_data, session_id)
logger.info(f"✅ Calendar generated successfully in {processing_time:.2f}s")
return calendar_data
elif progress and progress.get("status") == "failed":
@@ -96,7 +104,7 @@ class CalendarGenerationService:
logger.error(f"Traceback: {traceback.format_exc()}")
raise ContentPlanningErrorHandler.handle_general_error(e, "generate_comprehensive_calendar")
async def optimize_content_for_platform(self, user_id: int, title: str, description: str,
async def optimize_content_for_platform(self, user_id: str, title: str, description: str,
content_type: str, target_platform: str, event_id: Optional[int] = None) -> Dict[str, Any]:
"""Optimize content for specific platforms using the 12-step orchestrator."""
try:
@@ -138,7 +146,7 @@ class CalendarGenerationService:
logger.error(f"❌ Error optimizing content: {str(e)}")
raise ContentPlanningErrorHandler.handle_general_error(e, "optimize_content_for_platform")
async def predict_content_performance(self, user_id: int, content_type: str, platform: str,
async def predict_content_performance(self, user_id: str, content_type: str, platform: str,
content_data: Dict[str, Any], strategy_id: Optional[int] = None) -> Dict[str, Any]:
"""Predict content performance using the 12-step orchestrator."""
try:
@@ -172,7 +180,7 @@ class CalendarGenerationService:
logger.error(f"❌ Error predicting content performance: {str(e)}")
raise ContentPlanningErrorHandler.handle_general_error(e, "predict_content_performance")
async def repurpose_content_across_platforms(self, user_id: int, original_content: Dict[str, Any],
async def repurpose_content_across_platforms(self, user_id: str, original_content: Dict[str, Any],
target_platforms: List[str], strategy_id: Optional[int] = None) -> Dict[str, Any]:
"""Repurpose content across different platforms using the 12-step orchestrator."""
try:
@@ -217,7 +225,7 @@ class CalendarGenerationService:
logger.error(f"❌ Error repurposing content: {str(e)}")
raise ContentPlanningErrorHandler.handle_general_error(e, "repurpose_content_across_platforms")
async def get_trending_topics(self, user_id: int, industry: str, limit: int = 10) -> Dict[str, Any]:
async def get_trending_topics(self, user_id: str, industry: str, limit: int = 10) -> Dict[str, Any]:
"""Get trending topics relevant to the user's industry and content gaps using the 12-step orchestrator."""
try:
logger.info(f"📈 Getting trending topics for user {user_id} in {industry} using orchestrator")
@@ -257,7 +265,7 @@ class CalendarGenerationService:
logger.error(f"❌ Error getting trending topics: {str(e)}")
raise ContentPlanningErrorHandler.handle_general_error(e, "get_trending_topics")
async def get_comprehensive_user_data(self, user_id: int) -> Dict[str, Any]:
async def get_comprehensive_user_data(self, user_id: str) -> Dict[str, Any]:
"""Get comprehensive user data for calendar generation using the 12-step orchestrator."""
try:
logger.info(f"Getting comprehensive user data for user_id: {user_id} using orchestrator")
@@ -398,7 +406,7 @@ class CalendarGenerationService:
logger.error(f"❌ Failed to initialize orchestrator session: {e}")
return False
def _cleanup_old_sessions(self, user_id: int) -> None:
def _cleanup_old_sessions(self, user_id: str) -> None:
"""Clean up old sessions for a user."""
try:
current_time = datetime.now()
@@ -426,7 +434,7 @@ class CalendarGenerationService:
except Exception as e:
logger.error(f"❌ Error cleaning up old sessions: {e}")
def _get_active_session_for_user(self, user_id: int) -> Optional[str]:
def _get_active_session_for_user(self, user_id: str) -> Optional[str]:
"""Get active session for a user."""
try:
for session_id, session_data in self.orchestrator_sessions.items():
@@ -540,3 +548,67 @@ class CalendarGenerationService:
except Exception as e:
logger.error(f"❌ Error updating session progress: {e}")
async def _save_calendar_to_db(self, user_id: str, strategy_id: Optional[int], calendar_data: Dict[str, Any], session_id: str) -> None:
"""Save generated calendar to database."""
try:
if not self.db_session:
logger.warning("⚠️ No database session available, skipping persistence")
return
# Save session record
session_record = CalendarGenerationSession(
user_id=user_id,
strategy_id=strategy_id,
session_type=calendar_data.get("calendar_type", "monthly"),
generation_params={"session_id": session_id},
generated_calendar=calendar_data,
ai_insights=calendar_data.get("ai_insights"),
performance_predictions=calendar_data.get("performance_predictions"),
content_themes=calendar_data.get("weekly_themes"),
generation_status="completed",
ai_confidence=calendar_data.get("ai_confidence"),
processing_time=calendar_data.get("processing_time")
)
self.db_session.add(session_record)
self.db_session.flush() # Get ID
# Save calendar events
# Extract daily schedule from calendar data
daily_schedule = calendar_data.get("daily_schedule", [])
# If daily_schedule is not directly available, try to extract from step results
if not daily_schedule and "step_results" in calendar_data:
daily_schedule = calendar_data.get("step_results", {}).get("step_08", {}).get("daily_schedule", [])
for day in daily_schedule:
content_items = day.get("content_items", [])
for item in content_items:
# Parse date
date_str = day.get("date")
scheduled_date = datetime.utcnow()
if date_str:
try:
scheduled_date = datetime.fromisoformat(date_str)
except:
pass
event = CalendarEvent(
strategy_id=strategy_id if strategy_id else 0, # Fallback if no strategy
title=item.get("title", "Untitled Event"),
description=item.get("description"),
content_type=item.get("type", "social_post"),
platform=item.get("platform", "generic"),
scheduled_date=scheduled_date,
status="draft",
ai_recommendations=item
)
self.db_session.add(event)
self.db_session.commit()
logger.info(f"✅ Calendar saved to database for user {user_id}")
except Exception as e:
self.db_session.rollback()
logger.error(f"❌ Error saving calendar to database: {str(e)}")
# Don't raise, just log error so we don't fail the request if persistence fails

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
"""YouTube Creator Studio API endpoints."""

View File

@@ -0,0 +1,877 @@
"""
YouTube Creator Studio API Router
Handles video planning, scene building, and rendering endpoints.
"""
from typing import Any, Dict, List, Optional
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from loguru import logger
from sqlalchemy.orm import Session
from middleware.auth_middleware import get_current_user
from services.database import get_db
from services.youtube.planner import YouTubePlannerService
from services.youtube.scene_builder import YouTubeSceneBuilderService
from services.youtube.renderer import YouTubeVideoRendererService
from services.persona_data_service import PersonaDataService
from services.subscription import PricingService
from services.subscription.preflight_validator import validate_scene_animation_operation
from utils.logger_utils import get_service_logger
from utils.asset_tracker import save_asset_to_library
from .task_manager import task_manager
router = APIRouter(prefix="/youtube", tags=["youtube"])
logger = get_service_logger("api.youtube")
# Video output directory
base_dir = Path(__file__).parent.parent.parent.parent
YOUTUBE_VIDEO_DIR = base_dir / "youtube_videos"
YOUTUBE_VIDEO_DIR.mkdir(parents=True, exist_ok=True)
# Request/Response Models
class VideoPlanRequest(BaseModel):
"""Request model for video planning."""
user_idea: str = Field(..., description="User's video idea or topic")
duration_type: str = Field(
...,
pattern="^(shorts|medium|long)$",
description="Video duration type: shorts (≤60s), medium (1-4min), long (4-10min)"
)
reference_image_description: Optional[str] = Field(
None,
description="Optional description of reference image for visual inspiration"
)
source_content_id: Optional[str] = Field(
None,
description="Optional ID of source content (blog/story) to convert"
)
source_content_type: Optional[str] = Field(
None,
pattern="^(blog|story)$",
description="Type of source content: blog or story"
)
class VideoPlanResponse(BaseModel):
"""Response model for video plan."""
success: bool
plan: Optional[Dict[str, Any]] = None
message: str
class SceneBuildRequest(BaseModel):
"""Request model for scene building."""
video_plan: Dict[str, Any] = Field(..., description="Video plan from planning endpoint")
custom_script: Optional[str] = Field(
None,
description="Optional custom script to use instead of generating from plan"
)
class SceneBuildResponse(BaseModel):
"""Response model for scene building."""
success: bool
scenes: List[Dict[str, Any]] = []
message: str
class SceneUpdateRequest(BaseModel):
"""Request model for updating a single scene."""
scene_id: int = Field(..., description="Scene number to update")
narration: Optional[str] = None
visual_description: Optional[str] = None
duration_estimate: Optional[float] = None
enabled: Optional[bool] = None
class SceneUpdateResponse(BaseModel):
"""Response model for scene update."""
success: bool
scene: Optional[Dict[str, Any]] = None
message: str
class VideoRenderRequest(BaseModel):
"""Request model for video rendering."""
scenes: List[Dict[str, Any]] = Field(..., description="List of scenes to render")
video_plan: Dict[str, Any] = Field(..., description="Original video plan")
resolution: str = Field("720p", pattern="^(480p|720p|1080p)$", description="Video resolution")
combine_scenes: bool = Field(True, description="Whether to combine scenes into single video")
voice_id: str = Field("Wise_Woman", description="Voice ID for narration")
class VideoRenderResponse(BaseModel):
"""Response model for video rendering."""
success: bool
task_id: Optional[str] = None
message: str
class CostEstimateRequest(BaseModel):
"""Request model for cost estimation."""
scenes: List[Dict[str, Any]] = Field(..., description="List of scenes to estimate")
resolution: str = Field("720p", pattern="^(480p|720p|1080p)$", description="Video resolution")
class CostEstimateResponse(BaseModel):
"""Response model for cost estimation."""
success: bool
estimate: Optional[Dict[str, Any]] = None
message: str
# Helper function to get user ID
def require_authenticated_user(current_user: Dict[str, Any]) -> str:
"""Extract and validate user ID from current user."""
user_id = current_user.get("id") if current_user else None
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
return str(user_id)
@router.post("/plan", response_model=VideoPlanResponse)
async def create_video_plan(
request: VideoPlanRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_db),
) -> VideoPlanResponse:
"""
Generate a comprehensive video plan from user input.
This endpoint uses AI to create a detailed plan including:
- Video summary and target audience
- Content outline with timing
- Hook strategy and CTA
- Visual style recommendations
- SEO keywords
"""
try:
user_id = require_authenticated_user(current_user)
logger.info(
f"[YouTubeAPI] Planning video: idea={request.user_idea[:50]}..., "
f"duration={request.duration_type}, user={user_id}"
)
# Get persona data if available
persona_data = None
try:
persona_service = PersonaDataService()
persona_data = persona_service.get_user_persona_data(user_id)
except Exception as e:
logger.warning(f"[YouTubeAPI] Could not load persona data: {e}")
# Generate plan (optimized: for shorts, combine plan + scenes in one call)
planner = YouTubePlannerService()
plan = planner.generate_video_plan(
user_idea=request.user_idea,
duration_type=request.duration_type,
persona_data=persona_data,
reference_image_description=request.reference_image_description,
source_content_id=request.source_content_id,
source_content_type=request.source_content_type,
user_id=user_id,
include_scenes=(request.duration_type == "shorts"), # Optimize shorts
)
return VideoPlanResponse(
success=True,
plan=plan,
message="Video plan generated successfully"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error creating plan: {e}", exc_info=True)
return VideoPlanResponse(
success=False,
message=f"Failed to create video plan: {str(e)}"
)
@router.post("/scenes", response_model=SceneBuildResponse)
async def build_scenes(
request: SceneBuildRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> SceneBuildResponse:
"""
Build structured scenes from a video plan.
Converts the video plan into detailed scenes with:
- Narration text for each scene
- Visual descriptions and prompts
- Timing estimates
- Visual cues and emphasis tags
"""
try:
user_id = require_authenticated_user(current_user)
logger.info(
f"[YouTubeAPI] Building scenes: duration={request.video_plan.get('duration_type')}, "
f"custom_script={bool(request.custom_script)}, user={user_id}"
)
# Build scenes
scene_builder = YouTubeSceneBuilderService()
scenes = scene_builder.build_scenes_from_plan(
video_plan=request.video_plan,
user_id=user_id,
custom_script=request.custom_script,
)
return SceneBuildResponse(
success=True,
scenes=scenes,
message=f"Built {len(scenes)} scenes successfully"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error building scenes: {e}", exc_info=True)
return SceneBuildResponse(
success=False,
message=f"Failed to build scenes: {str(e)}"
)
@router.post("/scenes/{scene_id}/update", response_model=SceneUpdateResponse)
async def update_scene(
scene_id: int,
request: SceneUpdateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> SceneUpdateResponse:
"""
Update a single scene's narration, visual description, or duration.
This allows users to fine-tune individual scenes before rendering.
"""
try:
require_authenticated_user(current_user)
logger.info(f"[YouTubeAPI] Updating scene {scene_id}")
# In a full implementation, this would update a stored scene
# For now, return the updated scene data
updated_scene = {
"scene_number": scene_id,
"narration": request.narration,
"visual_description": request.visual_description,
"duration_estimate": request.duration_estimate,
"enabled": request.enabled if request.enabled is not None else True,
}
return SceneUpdateResponse(
success=True,
scene=updated_scene,
message="Scene updated successfully"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error updating scene: {e}", exc_info=True)
return SceneUpdateResponse(
success=False,
message=f"Failed to update scene: {str(e)}"
)
@router.post("/render", response_model=VideoRenderResponse)
async def start_video_render(
request: VideoRenderRequest,
background_tasks: BackgroundTasks,
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_db),
) -> VideoRenderResponse:
"""
Start rendering a video from scenes asynchronously.
This endpoint creates a background task that:
1. Generates narration audio for each scene
2. Renders each scene using WAN 2.5 text-to-video
3. Combines scenes into final video (if requested)
4. Saves to asset library
Returns task_id for polling progress.
"""
try:
user_id = require_authenticated_user(current_user)
# Validate subscription limits
pricing_service = PricingService(db)
validate_scene_animation_operation(
pricing_service=pricing_service,
user_id=user_id
)
# Filter enabled scenes
enabled_scenes = [s for s in request.scenes if s.get("enabled", True)]
if not enabled_scenes:
return VideoRenderResponse(
success=False,
message="No enabled scenes to render"
)
# VALIDATION: Pre-validate scenes before creating task to prevent wasted API calls
validation_errors = []
for scene in enabled_scenes:
scene_num = scene.get("scene_number", 0)
visual_prompt = (scene.get("enhanced_visual_prompt") or scene.get("visual_prompt", "")).strip()
if not visual_prompt:
validation_errors.append(f"Scene {scene_num}: Missing visual prompt")
elif len(visual_prompt) < 5:
validation_errors.append(f"Scene {scene_num}: Visual prompt too short ({len(visual_prompt)} chars, minimum 5)")
# Validate duration
duration = scene.get("duration_estimate", 5)
if duration < 1 or duration > 10:
validation_errors.append(f"Scene {scene_num}: Invalid duration ({duration}s, must be 1-10 seconds)")
if validation_errors:
error_msg = "Validation failed: " + "; ".join(validation_errors)
logger.warning(f"[YouTubeAPI] {error_msg}")
return VideoRenderResponse(
success=False,
message=error_msg + ". Please fix these issues before rendering."
)
logger.info(
f"[YouTubeAPI] Starting render: {len(enabled_scenes)} scenes, "
f"resolution={request.resolution}, user={user_id}"
)
# Create async task
task_id = task_manager.create_task("youtube_video_render")
logger.info(
f"[YouTubeAPI] Created task {task_id} for user {user_id}, "
f"scenes={len(enabled_scenes)}, resolution={request.resolution}"
)
# Verify task was created
initial_status = task_manager.get_task_status(task_id)
if not initial_status:
logger.error(f"[YouTubeAPI] Failed to create task {task_id} - task not found immediately after creation")
return VideoRenderResponse(
success=False,
message="Failed to create render task. Please try again."
)
# Add background task
try:
background_tasks.add_task(
_execute_video_render_task,
task_id=task_id,
scenes=enabled_scenes,
video_plan=request.video_plan,
user_id=user_id,
resolution=request.resolution,
combine_scenes=request.combine_scenes,
voice_id=request.voice_id,
)
logger.info(f"[YouTubeAPI] Background task added for task {task_id}")
except Exception as bg_error:
logger.error(f"[YouTubeAPI] Failed to add background task for {task_id}: {bg_error}", exc_info=True)
# Mark task as failed
task_manager.update_task_status(
task_id,
"failed",
error=str(bg_error),
message="Failed to start background render task"
)
return VideoRenderResponse(
success=False,
message=f"Failed to start render task: {str(bg_error)}"
)
return VideoRenderResponse(
success=True,
task_id=task_id,
message=f"Video rendering started. Processing {len(enabled_scenes)} scenes..."
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error starting render: {e}", exc_info=True)
return VideoRenderResponse(
success=False,
message=f"Failed to start render: {str(e)}"
)
@router.get("/render/{task_id}")
async def get_render_status(
task_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> Dict[str, Any]:
"""
Get the status of a video rendering task.
Returns current progress, status, and result when complete.
"""
try:
require_authenticated_user(current_user)
logger.debug(f"[YouTubeAPI] Getting render status for task: {task_id}")
task_status = task_manager.get_task_status(task_id)
if not task_status:
logger.warning(
f"[YouTubeAPI] Task {task_id} not found. "
f"Available tasks: {list(task_manager.task_storage.keys())[:5]}..."
)
raise HTTPException(
status_code=404,
detail={
"error": "Task not found",
"message": "The render task was not found. It may have expired, been cleaned up, or the server may have restarted.",
"task_id": task_id,
"user_action": "Please try rendering again."
}
)
return task_status
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error getting render status: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to get render status: {str(e)}"
)
def _execute_video_render_task(
task_id: str,
scenes: List[Dict[str, Any]],
video_plan: Dict[str, Any],
user_id: str,
resolution: str,
combine_scenes: bool,
voice_id: str,
):
"""Background task to render video with progress updates."""
logger.info(
f"[YouTubeRenderer] Background task started for task {task_id}, "
f"scenes={len(scenes)}, user={user_id}"
)
# Verify task exists before starting
task_status = task_manager.get_task_status(task_id)
if not task_status:
logger.error(
f"[YouTubeRenderer] Task {task_id} not found when background task started. "
f"This should not happen - task may have been cleaned up."
)
return
try:
task_manager.update_task_status(
task_id, "processing", progress=5.0, message="Initializing render..."
)
logger.info(f"[YouTubeRenderer] Task {task_id} status updated to processing")
renderer = YouTubeVideoRendererService()
total_scenes = len(scenes)
scene_results = []
total_cost = 0.0
# VALIDATION: Pre-validate all scenes before starting expensive API calls
invalid_scenes = []
for idx, scene in enumerate(scenes):
scene_num = scene.get("scene_number", idx + 1)
visual_prompt = (scene.get("enhanced_visual_prompt") or scene.get("visual_prompt", "")).strip()
if not visual_prompt:
invalid_scenes.append({
"scene_number": scene_num,
"reason": "Missing visual prompt",
"prompt_length": 0
})
elif len(visual_prompt) < 5:
invalid_scenes.append({
"scene_number": scene_num,
"reason": f"Visual prompt too short ({len(visual_prompt)} chars, minimum 5)",
"prompt_length": len(visual_prompt)
})
# Validate duration
duration = scene.get("duration_estimate", 5)
if duration < 1 or duration > 10:
invalid_scenes.append({
"scene_number": scene_num,
"reason": f"Invalid duration ({duration}s, must be 1-10 seconds)",
"prompt_length": len(visual_prompt) if visual_prompt else 0
})
if invalid_scenes:
error_msg = f"Found {len(invalid_scenes)} invalid scene(s) before rendering: " + \
", ".join([f"Scene {s['scene_number']} ({s['reason']})" for s in invalid_scenes])
logger.error(f"[YouTubeRenderer] {error_msg}")
task_manager.update_task_status(
task_id,
"failed",
error=error_msg,
message=f"Validation failed: {len(invalid_scenes)} scene(s) have invalid data. Please fix them before rendering."
)
return
# Render each scene
for idx, scene in enumerate(scenes):
scene_num = scene.get("scene_number", idx + 1)
progress = 5.0 + (idx / total_scenes) * 85.0
task_manager.update_task_status(
task_id,
"processing",
progress=progress,
message=f"Rendering scene {scene_num}/{total_scenes}..."
)
try:
scene_result = renderer.render_scene_video(
scene=scene,
video_plan=video_plan,
user_id=user_id,
resolution=resolution,
generate_audio_enabled=True,
voice_id=voice_id,
)
scene_results.append(scene_result)
total_cost += scene_result["cost"]
# Save to asset library
try:
from services.database import get_db
db = next(get_db())
try:
save_asset_to_library(
db=db,
user_id=user_id,
asset_type="video",
source_module="youtube_creator",
filename=scene_result["video_filename"],
file_url=scene_result["video_url"],
file_path=scene_result["video_path"],
file_size=scene_result["file_size"],
mime_type="video/mp4",
title=f"YouTube Scene {scene_num}: {scene.get('title', 'Untitled')}",
description=f"Scene {scene_num} from YouTube video",
prompt=scene.get("visual_prompt", ""),
tags=["youtube_creator", "video", "scene", f"scene_{scene_num}", resolution],
provider="wavespeed",
model="alibaba/wan-2.5/text-to-video",
cost=scene_result["cost"],
asset_metadata={
"scene_number": scene_num,
"duration": scene_result["duration"],
"resolution": resolution,
"status": "completed"
}
)
finally:
db.close()
except Exception as e:
logger.warning(f"[YouTubeRenderer] Failed to save scene to library: {e}")
except Exception as scene_error:
error_msg = str(scene_error)
scene_error_type = "unknown"
if isinstance(scene_error, HTTPException):
error_detail = scene_error.detail
if isinstance(error_detail, dict):
error_msg = error_detail.get("message", error_detail.get("error", str(error_detail)))
scene_error_type = error_detail.get("error", "http_error")
else:
error_msg = str(error_detail)
# Check if it's a timeout or critical error that should fail fast
if scene_error.status_code == 504: # Timeout
scene_error_type = "timeout"
elif scene_error.status_code >= 500: # Server errors
scene_error_type = "server_error"
else:
# Check error type from exception
if "timeout" in str(scene_error).lower():
scene_error_type = "timeout"
elif "connection" in str(scene_error).lower():
scene_error_type = "connection_error"
logger.error(
f"[YouTubeRenderer] Scene {scene_num} failed: {error_msg} (type: {scene_error_type})",
exc_info=True
)
# Track failed scene for user retry
failed_scene_result = {
"scene_number": scene_num,
"status": "failed",
"error": error_msg,
"error_type": scene_error_type,
"scene_data": scene,
}
scene_results.append(failed_scene_result)
# Update task status immediately to reflect failure
successful_count = len([r for r in scene_results if r.get("status") != "failed"])
failed_count = len([r for r in scene_results if r.get("status") == "failed"])
# Fail fast for critical errors (timeouts, server errors) if it's the first scene
# or if multiple consecutive failures occur
should_fail_fast = (
scene_error_type in ["timeout", "server_error", "connection_error"] and
(failed_count == 1 or failed_count >= 3) # Fail fast on first timeout or 3+ failures
)
if should_fail_fast:
logger.error(
f"[YouTubeRenderer] Failing fast due to {scene_error_type} error. "
f"Scene {scene_num} failed, total failures: {failed_count}"
)
# Mark task as failed immediately
task_manager.update_task_status(
task_id,
"failed",
error=f"Render failed fast: Scene {scene_num} failed with {scene_error_type}",
message=f"Video rendering stopped early due to {scene_error_type}. "
f"{successful_count} scene(s) completed, {failed_count} scene(s) failed. "
f"Failed scene: {error_msg}",
)
# Update result with current state
successful_scenes = [r for r in scene_results if r.get("status") != "failed"]
failed_scenes = [r for r in scene_results if r.get("status") == "failed"]
result = {
"scene_results": successful_scenes,
"failed_scenes": failed_scenes,
"total_cost": total_cost,
"final_video_url": successful_scenes[0]["video_url"] if successful_scenes else None,
"num_scenes": len(successful_scenes),
"num_failed": len(failed_scenes),
"resolution": resolution,
"partial_success": len(failed_scenes) > 0 and len(successful_scenes) > 0,
"fail_fast": True,
"fail_reason": f"Scene {scene_num} failed with {scene_error_type}",
}
task_manager.update_task_status(
task_id,
"failed",
error=f"Render failed fast: {scene_error_type}",
message=f"Rendering stopped early. {successful_count} completed, {failed_count} failed.",
result=result
)
return # Exit immediately
# For non-critical errors, update progress but continue
task_manager.update_task_status(
task_id,
"processing",
progress=progress,
message=f"Scene {scene_num} failed, continuing with remaining scenes... "
f"({successful_count} successful, {failed_count} failed)"
)
# Continue with other scenes - let user retry failed ones
continue
# Separate successful and failed scenes
successful_scenes = [r for r in scene_results if r.get("status") != "failed"]
failed_scenes = [r for r in scene_results if r.get("status") == "failed"]
if not successful_scenes:
# All scenes failed - mark as failed immediately
error_msg = f"All {len(failed_scenes)} scene(s) failed to render"
logger.error(f"[YouTubeRenderer] {error_msg}")
task_manager.update_task_status(
task_id,
"failed",
error=error_msg,
message=f"All scenes failed. First error: {failed_scenes[0].get('error', 'Unknown') if failed_scenes else 'Unknown'}",
result={
"scene_results": [],
"failed_scenes": failed_scenes,
"total_cost": 0.0,
"final_video_url": None,
"num_scenes": 0,
"num_failed": len(failed_scenes),
"resolution": resolution,
"partial_success": False,
}
)
return
# Combine scenes if requested (only if we have successful scenes)
final_video_url = None
if combine_scenes and len(successful_scenes) > 1:
task_manager.update_task_status(
task_id, "processing", progress=90.0, message="Combining scenes..."
)
# Use renderer to combine
combined_result = renderer.render_full_video(
scenes=scenes,
video_plan=video_plan,
user_id=user_id,
resolution=resolution,
combine_scenes=True,
voice_id=voice_id,
)
final_video_url = combined_result.get("final_video_url")
# Final result (successful_scenes and failed_scenes already separated above)
result = {
"scene_results": successful_scenes,
"failed_scenes": failed_scenes,
"total_cost": total_cost,
"final_video_url": final_video_url or (successful_scenes[0]["video_url"] if successful_scenes else None),
"num_successful": len(successful_scenes),
"num_failed": len(failed_scenes),
"resolution": resolution,
"partial_success": len(failed_scenes) > 0 and len(successful_scenes) > 0,
}
# Determine final status based on results
if len(failed_scenes) == 0:
# All scenes succeeded
final_status = "completed"
final_message = f"Video rendering complete! {len(successful_scenes)} scene(s) rendered successfully."
elif len(successful_scenes) > 0:
# Partial success
final_status = "completed" # Still mark as completed but with partial success flag
final_message = f"Video rendering completed with {len(failed_scenes)} failure(s). " \
f"{len(successful_scenes)} scene(s) rendered successfully."
else:
# This shouldn't happen due to early return above, but handle it
final_status = "failed"
final_message = f"All scenes failed to render."
task_manager.update_task_status(
task_id,
final_status,
progress=100.0,
message=final_message,
result=result
)
logger.info(
f"[YouTubeRenderer] ✅ Render task {task_id} completed: "
f"{len(scene_results)} scenes, cost=${total_cost:.2f}"
)
except HTTPException as exc:
error_msg = str(exc.detail) if isinstance(exc.detail, str) else exc.detail.get("error", "Render failed") if isinstance(exc.detail, dict) else "Render failed"
logger.error(f"[YouTubeRenderer] Render task {task_id} failed: {error_msg}")
task_manager.update_task_status(
task_id,
"failed",
error=error_msg,
message=f"Video rendering failed: {error_msg}",
)
except Exception as exc:
error_msg = str(exc)
logger.error(f"[YouTubeRenderer] Render task {task_id} error: {error_msg}", exc_info=True)
task_manager.update_task_status(
task_id,
"failed",
error=error_msg,
message=f"Video rendering error: {error_msg}",
)
@router.post("/estimate-cost", response_model=CostEstimateResponse)
async def estimate_render_cost(
request: CostEstimateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> CostEstimateResponse:
"""
Estimate the cost of rendering a video before actually rendering it.
This endpoint calculates the expected cost based on:
- Number of enabled scenes
- Duration of each scene
- Selected resolution
Returns a detailed cost breakdown.
"""
try:
require_authenticated_user(current_user)
logger.info(
f"[YouTubeAPI] Estimating cost: {len(request.scenes)} scenes, "
f"resolution={request.resolution}"
)
renderer = YouTubeVideoRendererService()
estimate = renderer.estimate_render_cost(
scenes=request.scenes,
resolution=request.resolution,
)
return CostEstimateResponse(
success=True,
estimate=estimate,
message="Cost estimate calculated successfully"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error estimating cost: {e}", exc_info=True)
return CostEstimateResponse(
success=False,
message=f"Failed to estimate cost: {str(e)}"
)
@router.get("/videos/{video_filename}")
async def serve_youtube_video(
video_filename: str,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> FileResponse:
"""
Serve YouTube video files.
This endpoint serves video files generated by the YouTube Creator Studio.
Videos are stored in the youtube_videos directory.
"""
try:
require_authenticated_user(current_user)
# Security: prevent directory traversal
if ".." in video_filename or "/" in video_filename or "\\" in video_filename:
raise HTTPException(status_code=400, detail="Invalid filename")
video_path = YOUTUBE_VIDEO_DIR / video_filename
if not video_path.exists():
raise HTTPException(status_code=404, detail="Video not found")
if not video_path.is_file():
raise HTTPException(status_code=400, detail="Invalid video path")
logger.debug(f"[YouTubeAPI] Serving video: {video_filename}")
return FileResponse(
path=str(video_path),
media_type="video/mp4",
filename=video_filename,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error serving video: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to serve video: {str(e)}"
)

View File

@@ -0,0 +1,11 @@
"""
Task Manager for YouTube Creator Studio
Reuses the Story Writer task manager pattern for async video rendering.
"""
from api.story_writer.task_manager import TaskManager
# Shared task manager instance
task_manager = TaskManager()