WIP: AI Podcast Maker and YouTube Creator Studio integration
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
1013
backend/api/podcast/router.py
Normal file
1013
backend/api/podcast/router.py
Normal file
File diff suppressed because it is too large
Load Diff
2
backend/api/youtube/__init__.py
Normal file
2
backend/api/youtube/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""YouTube Creator Studio API endpoints."""
|
||||
|
||||
877
backend/api/youtube/router.py
Normal file
877
backend/api/youtube/router.py
Normal 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)}"
|
||||
)
|
||||
|
||||
11
backend/api/youtube/task_manager.py
Normal file
11
backend/api/youtube/task_manager.py
Normal 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()
|
||||
|
||||
Reference in New Issue
Block a user