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()
|
||||
|
||||
@@ -305,6 +305,14 @@ app.include_router(product_marketing_router)
|
||||
from api.content_assets.router import router as content_assets_router
|
||||
app.include_router(content_assets_router)
|
||||
|
||||
# Include Podcast Maker router
|
||||
from api.podcast.router import router as podcast_router
|
||||
app.include_router(podcast_router)
|
||||
|
||||
# Include YouTube Creator Studio router
|
||||
from api.youtube.router import router as youtube_router
|
||||
app.include_router(youtube_router, prefix="/api")
|
||||
|
||||
# Include research configuration router
|
||||
app.include_router(research_config_router, prefix="/api/research", tags=["research"])
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ class AssetSource(enum.Enum):
|
||||
# Product Marketing Suite
|
||||
PRODUCT_MARKETING = "product_marketing"
|
||||
|
||||
# Podcast Maker
|
||||
PODCAST_MAKER = "podcast_maker"
|
||||
|
||||
|
||||
class ContentAsset(Base):
|
||||
"""
|
||||
|
||||
65
backend/models/podcast_models.py
Normal file
65
backend/models/podcast_models.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Podcast Maker Models
|
||||
|
||||
Database models for podcast project persistence and state management.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, JSON, Text, Index
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from datetime import datetime
|
||||
|
||||
# Use the same Base as subscription models for consistency
|
||||
from models.subscription_models import Base
|
||||
|
||||
|
||||
class PodcastProject(Base):
|
||||
"""
|
||||
Database model for podcast project state.
|
||||
Stores complete project state to enable cross-device resume.
|
||||
"""
|
||||
|
||||
__tablename__ = "podcast_projects"
|
||||
|
||||
# Primary fields
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
project_id = Column(String(255), unique=True, nullable=False, index=True) # User-facing project ID
|
||||
user_id = Column(String(255), nullable=False, index=True) # Clerk user ID
|
||||
|
||||
# Project metadata
|
||||
idea = Column(String(1000), nullable=False) # Episode idea or URL
|
||||
duration = Column(Integer, nullable=False) # Duration in minutes
|
||||
speakers = Column(Integer, nullable=False, default=1) # Number of speakers
|
||||
budget_cap = Column(Float, nullable=False, default=50.0) # Budget cap in USD
|
||||
|
||||
# Project state (stored as JSON)
|
||||
# This mirrors the PodcastProjectState interface from frontend
|
||||
analysis = Column(JSON, nullable=True) # PodcastAnalysis
|
||||
queries = Column(JSON, nullable=True) # List[Query]
|
||||
selected_queries = Column(JSON, nullable=True) # Array of query IDs
|
||||
research = Column(JSON, nullable=True) # Research object
|
||||
raw_research = Column(JSON, nullable=True) # BlogResearchResponse
|
||||
estimate = Column(JSON, nullable=True) # PodcastEstimate
|
||||
script_data = Column(JSON, nullable=True) # Script object
|
||||
render_jobs = Column(JSON, nullable=True) # List[Job]
|
||||
knobs = Column(JSON, nullable=True) # Knobs settings
|
||||
research_provider = Column(String(50), nullable=True, default="google") # Research provider
|
||||
|
||||
# UI state
|
||||
show_script_editor = Column(Boolean, default=False)
|
||||
show_render_queue = Column(Boolean, default=False)
|
||||
current_step = Column(String(50), nullable=True) # 'create' | 'analysis' | 'research' | 'script' | 'render'
|
||||
|
||||
# Status
|
||||
status = Column(String(50), default="draft", nullable=False, index=True) # draft, in_progress, completed, archived
|
||||
is_favorite = Column(Boolean, default=False, index=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Composite indexes for common query patterns
|
||||
__table_args__ = (
|
||||
Index('idx_user_status_created', 'user_id', 'status', 'created_at'),
|
||||
Index('idx_user_favorite_updated', 'user_id', 'is_favorite', 'updated_at'),
|
||||
)
|
||||
|
||||
@@ -74,8 +74,9 @@ class ProductAsset(Base):
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Additional metadata
|
||||
metadata = Column(JSON, nullable=True) # Additional product-specific metadata
|
||||
# Additional metadata (renamed from 'metadata' to avoid SQLAlchemy reserved name conflict)
|
||||
# Using 'product_metadata' as column name in DB to avoid conflict with SQLAlchemy's reserved 'metadata' attribute
|
||||
product_metadata = Column('product_metadata', JSON, nullable=True) # Additional product-specific metadata
|
||||
|
||||
# Composite indexes
|
||||
__table_args__ = (
|
||||
|
||||
149
backend/scripts/create_podcast_tables.py
Normal file
149
backend/scripts/create_podcast_tables.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Database Migration Script for Podcast Maker
|
||||
Creates the podcast_projects table for cross-device project persistence.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add the backend directory to Python path
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from loguru import logger
|
||||
import traceback
|
||||
|
||||
# Import models - PodcastProject uses SubscriptionBase
|
||||
from models.subscription_models import Base as SubscriptionBase
|
||||
from models.podcast_models import PodcastProject
|
||||
from services.database import DATABASE_URL
|
||||
|
||||
def create_podcast_tables():
|
||||
"""Create podcast-related tables."""
|
||||
|
||||
try:
|
||||
# Create engine
|
||||
engine = create_engine(DATABASE_URL, echo=False)
|
||||
|
||||
# Create all tables (PodcastProject uses SubscriptionBase, so it will be created)
|
||||
logger.info("Creating podcast maker tables...")
|
||||
SubscriptionBase.metadata.create_all(bind=engine)
|
||||
logger.info("✅ Podcast tables created successfully")
|
||||
|
||||
# Verify table was created
|
||||
display_setup_summary(engine)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error creating podcast tables: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def display_setup_summary(engine):
|
||||
"""Display a summary of the created tables."""
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
logger.info("\n" + "="*60)
|
||||
logger.info("PODCAST MAKER SETUP SUMMARY")
|
||||
logger.info("="*60)
|
||||
|
||||
# Check if table exists
|
||||
check_query = text("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='podcast_projects'
|
||||
""")
|
||||
|
||||
result = conn.execute(check_query)
|
||||
table_exists = result.fetchone()
|
||||
|
||||
if table_exists:
|
||||
logger.info("✅ Table 'podcast_projects' created successfully")
|
||||
|
||||
# Get table schema
|
||||
schema_query = text("""
|
||||
SELECT sql FROM sqlite_master
|
||||
WHERE type='table' AND name='podcast_projects'
|
||||
""")
|
||||
result = conn.execute(schema_query)
|
||||
schema = result.fetchone()
|
||||
if schema:
|
||||
logger.info("\n📋 Table Schema:")
|
||||
logger.info(schema[0])
|
||||
|
||||
# Check indexes
|
||||
indexes_query = text("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='index' AND tbl_name='podcast_projects'
|
||||
""")
|
||||
result = conn.execute(indexes_query)
|
||||
indexes = result.fetchall()
|
||||
|
||||
if indexes:
|
||||
logger.info(f"\n📊 Indexes ({len(indexes)}):")
|
||||
for idx in indexes:
|
||||
logger.info(f" • {idx[0]}")
|
||||
|
||||
else:
|
||||
logger.warning("⚠️ Table 'podcast_projects' not found after creation")
|
||||
|
||||
logger.info("\n" + "="*60)
|
||||
logger.info("NEXT STEPS:")
|
||||
logger.info("="*60)
|
||||
logger.info("1. The podcast_projects table is ready for use")
|
||||
logger.info("2. Projects will automatically sync to database after major steps")
|
||||
logger.info("3. Users can resume projects from any device")
|
||||
logger.info("4. Use the 'My Projects' button in the Podcast Dashboard to view saved projects")
|
||||
logger.info("="*60)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error displaying summary: {e}")
|
||||
|
||||
def check_existing_table(engine):
|
||||
"""Check if podcast_projects table already exists."""
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
check_query = text("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='podcast_projects'
|
||||
""")
|
||||
|
||||
result = conn.execute(check_query)
|
||||
table_exists = result.fetchone()
|
||||
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Table 'podcast_projects' already exists")
|
||||
logger.info(" Running migration will ensure schema is up to date...")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking existing table: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("🚀 Starting podcast maker database migration...")
|
||||
|
||||
try:
|
||||
# Create engine to check existing table
|
||||
engine = create_engine(DATABASE_URL, echo=False)
|
||||
|
||||
# Check existing table
|
||||
table_exists = check_existing_table(engine)
|
||||
|
||||
# Create tables (idempotent - won't recreate if exists)
|
||||
create_podcast_tables()
|
||||
|
||||
logger.info("✅ Migration completed successfully!")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Migration cancelled by user")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Migration failed: {e}")
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
141
backend/scripts/migrate_all_tables_to_string.py
Normal file
141
backend/scripts/migrate_all_tables_to_string.py
Normal file
@@ -0,0 +1,141 @@
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import text
|
||||
from services.database import SessionLocal, engine
|
||||
|
||||
# Import models to ensure they are registered and we can recreate them
|
||||
from models.content_planning import (
|
||||
ContentStrategy, ContentGapAnalysis, ContentRecommendation, AIAnalysisResult,
|
||||
Base as ContentPlanningBase
|
||||
)
|
||||
from models.enhanced_calendar_models import (
|
||||
ContentCalendarTemplate, AICalendarRecommendation, ContentPerformanceTracking,
|
||||
ContentTrendAnalysis, ContentOptimization, CalendarGenerationSession,
|
||||
Base as EnhancedCalendarBase
|
||||
)
|
||||
|
||||
def migrate_table(db, table_name, base_metadata):
|
||||
"""Migrate user_id column for a specific table from INTEGER to VARCHAR(255)."""
|
||||
try:
|
||||
logger.info(f"Checking table: {table_name}")
|
||||
|
||||
# Check if table exists
|
||||
check_table_query = f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';"
|
||||
result = db.execute(text(check_table_query))
|
||||
if not result.scalar():
|
||||
logger.warning(f"Table '{table_name}' does not exist. Skipping check, but will try to create it.")
|
||||
# If it doesn't exist, we can just create it with the new schema
|
||||
try:
|
||||
base_metadata.create_all(bind=engine, tables=[base_metadata.tables[table_name]], checkfirst=True)
|
||||
logger.success(f"✅ Created {table_name} with new schema")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create {table_name}: {e}")
|
||||
return True
|
||||
|
||||
# Check current column type
|
||||
check_column_query = f"SELECT type FROM pragma_table_info('{table_name}') WHERE name = 'user_id';"
|
||||
result = db.execute(text(check_column_query))
|
||||
current_type = result.scalar()
|
||||
|
||||
if not current_type:
|
||||
logger.info(f"Table {table_name} does not have user_id column. Skipping.")
|
||||
return True
|
||||
|
||||
if 'varchar' in current_type.lower() or 'text' in current_type.lower():
|
||||
logger.info(f"✅ {table_name}.user_id is already {current_type}. No migration needed.")
|
||||
return True
|
||||
|
||||
logger.info(f"Migrating {table_name}.user_id from {current_type} to VARCHAR...")
|
||||
|
||||
# Backup data
|
||||
backup_table = f"{table_name}_backup"
|
||||
db.execute(text(f"DROP TABLE IF EXISTS {backup_table}")) # Ensure clean state
|
||||
db.execute(text(f"CREATE TABLE {backup_table} AS SELECT * FROM {table_name}"))
|
||||
|
||||
# Drop old table
|
||||
db.execute(text(f"DROP TABLE {table_name}"))
|
||||
|
||||
# Recreate table
|
||||
# We need to find the Table object in metadata
|
||||
table_obj = base_metadata.tables.get(table_name)
|
||||
if table_obj is not None:
|
||||
base_metadata.create_all(bind=engine, tables=[table_obj], checkfirst=False)
|
||||
else:
|
||||
logger.error(f"Could not find Table object for {table_name} in metadata")
|
||||
# Restore backup and abort
|
||||
db.execute(text(f"ALTER TABLE {backup_table} RENAME TO {table_name}"))
|
||||
return False
|
||||
|
||||
# Restore data
|
||||
# We need to list columns to construct INSERT statement, excluding those that might be auto-generated if needed,
|
||||
# but usually for restore we want all.
|
||||
# However, we need to cast user_id to TEXT.
|
||||
|
||||
# Get columns from backup
|
||||
columns_result = db.execute(text(f"PRAGMA table_info({backup_table})"))
|
||||
columns = [row[1] for row in columns_result]
|
||||
|
||||
cols_str = ", ".join(columns)
|
||||
|
||||
# Construct select list with cast
|
||||
select_parts = []
|
||||
for col in columns:
|
||||
if col == 'user_id':
|
||||
select_parts.append("CAST(user_id AS TEXT)")
|
||||
else:
|
||||
select_parts.append(col)
|
||||
select_str = ", ".join(select_parts)
|
||||
|
||||
restore_query = f"INSERT INTO {table_name} ({cols_str}) SELECT {select_str} FROM {backup_table}"
|
||||
db.execute(text(restore_query))
|
||||
|
||||
# Drop backup
|
||||
db.execute(text(f"DROP TABLE {backup_table}"))
|
||||
|
||||
db.commit()
|
||||
logger.success(f"✅ Migrated {table_name} successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to migrate {table_name}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
def migrate_all():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Content Planning Tables
|
||||
cp_tables = [
|
||||
"content_strategies",
|
||||
"content_gap_analyses",
|
||||
"content_recommendations",
|
||||
"ai_analysis_results"
|
||||
]
|
||||
|
||||
for table in cp_tables:
|
||||
migrate_table(db, table, ContentPlanningBase.metadata)
|
||||
|
||||
# Enhanced Calendar Tables
|
||||
ec_tables = [
|
||||
"content_calendar_templates",
|
||||
"ai_calendar_recommendations",
|
||||
"content_performance_tracking",
|
||||
"content_trend_analysis",
|
||||
"content_optimizations",
|
||||
"calendar_generation_sessions"
|
||||
]
|
||||
|
||||
for table in ec_tables:
|
||||
migrate_table(db, table, EnhancedCalendarBase.metadata)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Starting comprehensive user_id migration...")
|
||||
migrate_all()
|
||||
logger.info("Migration finished.")
|
||||
42
backend/scripts/verify_podcast_table.py
Normal file
42
backend/scripts/verify_podcast_table.py
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Verify that the podcast_projects table exists and has the correct structure.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from services.database import engine
|
||||
|
||||
def verify_table():
|
||||
"""Verify the podcast_projects table exists."""
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'podcast_projects' in tables:
|
||||
print("✅ Table 'podcast_projects' exists")
|
||||
|
||||
columns = inspector.get_columns('podcast_projects')
|
||||
print(f"\n📊 Columns ({len(columns)}):")
|
||||
for col in columns:
|
||||
print(f" • {col['name']}: {col['type']}")
|
||||
|
||||
indexes = inspector.get_indexes('podcast_projects')
|
||||
print(f"\n📈 Indexes ({len(indexes)}):")
|
||||
for idx in indexes:
|
||||
print(f" • {idx['name']}: {idx['column_names']}")
|
||||
|
||||
return True
|
||||
else:
|
||||
print("❌ Table 'podcast_projects' not found")
|
||||
print(f"Available tables: {', '.join(tables)}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = verify_table()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
@@ -29,17 +29,15 @@ class ExaResearchProvider(BaseProvider):
|
||||
# Determine category: use exa_category if set, otherwise map from source_types
|
||||
category = config.exa_category if config.exa_category else self._map_source_type_to_category(config.source_types)
|
||||
|
||||
# Build search kwargs
|
||||
# Build search kwargs - use correct Exa API format
|
||||
search_kwargs = {
|
||||
'type': config.exa_search_type or "auto",
|
||||
'num_results': min(config.max_sources, 25),
|
||||
'contents': {
|
||||
'text': {'max_characters': 1000},
|
||||
'summary': {'query': f"Key insights about {topic}"},
|
||||
'highlights': {
|
||||
'num_sentences': 2,
|
||||
'highlights_per_url': 3
|
||||
}
|
||||
'text': {'max_characters': 1000},
|
||||
'summary': {'query': f"Key insights about {topic}"},
|
||||
'highlights': {
|
||||
'num_sentences': 2,
|
||||
'highlights_per_url': 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +51,39 @@ class ExaResearchProvider(BaseProvider):
|
||||
|
||||
logger.info(f"[Exa Research] Executing search: {query}")
|
||||
|
||||
# Execute Exa search
|
||||
results = self.exa.search_and_contents(query, **search_kwargs)
|
||||
# Execute Exa search - pass contents parameters directly, not nested
|
||||
try:
|
||||
results = self.exa.search_and_contents(
|
||||
query,
|
||||
text={'max_characters': 1000},
|
||||
summary={'query': f"Key insights about {topic}"},
|
||||
highlights={'num_sentences': 2, 'highlights_per_url': 3},
|
||||
type=config.exa_search_type or "auto",
|
||||
num_results=min(config.max_sources, 25),
|
||||
**({k: v for k, v in {
|
||||
'category': category,
|
||||
'include_domains': config.exa_include_domains,
|
||||
'exclude_domains': config.exa_exclude_domains
|
||||
}.items() if v})
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Exa Research] API call failed: {e}")
|
||||
# Try simpler call without contents if the above fails
|
||||
try:
|
||||
logger.info("[Exa Research] Retrying with simplified parameters")
|
||||
results = self.exa.search_and_contents(
|
||||
query,
|
||||
type=config.exa_search_type or "auto",
|
||||
num_results=min(config.max_sources, 25),
|
||||
**({k: v for k, v in {
|
||||
'category': category,
|
||||
'include_domains': config.exa_include_domains,
|
||||
'exclude_domains': config.exa_exclude_domains
|
||||
}.items() if v})
|
||||
)
|
||||
except Exception as retry_error:
|
||||
logger.error(f"[Exa Research] Retry also failed: {retry_error}")
|
||||
raise RuntimeError(f"Exa search failed: {str(retry_error)}") from retry_error
|
||||
|
||||
# Transform to standardized format
|
||||
sources = self._transform_sources(results.results)
|
||||
|
||||
@@ -52,45 +52,44 @@ class BasicResearchStrategy(ResearchStrategy):
|
||||
target_audience: str,
|
||||
config: ResearchConfig
|
||||
) -> str:
|
||||
"""Build basic research prompt focused on keywords and quick insights."""
|
||||
prompt = f"""You are a professional blog content strategist researching for a {industry} blog targeting {target_audience}.
|
||||
"""Build basic research prompt focused on podcast-ready, actionable insights."""
|
||||
prompt = f"""You are a podcast researcher creating TALKING POINTS and FACT CARDS for a {industry} audience of {target_audience}.
|
||||
|
||||
Research Topic: "{topic}"
|
||||
|
||||
Provide analysis in this EXACT format:
|
||||
|
||||
## CURRENT TRENDS (2024-2025)
|
||||
- [Trend 1 with specific data and source URL]
|
||||
- [Trend 2 with specific data and source URL]
|
||||
- [Trend 3 with specific data and source URL]
|
||||
## PODCAST HOOKS (3)
|
||||
- [Hook line with tension + data point + source URL]
|
||||
|
||||
## KEY STATISTICS
|
||||
- [Statistic 1: specific number/percentage with source URL]
|
||||
- [Statistic 2: specific number/percentage with source URL]
|
||||
- [Statistic 3: specific number/percentage with source URL]
|
||||
- [Statistic 4: specific number/percentage with source URL]
|
||||
- [Statistic 5: specific number/percentage with source URL]
|
||||
## OBJECTIONS & COUNTERS (3)
|
||||
- Objection: [common listener objection]
|
||||
Counter: [concise rebuttal with stat + source URL]
|
||||
|
||||
## PRIMARY KEYWORDS
|
||||
1. "{topic}" (main keyword)
|
||||
2. [Variation 1]
|
||||
3. [Variation 2]
|
||||
## KEY STATS & PROOF (6)
|
||||
- [Specific metric with %/number, date, and source URL]
|
||||
|
||||
## SECONDARY KEYWORDS
|
||||
[5 related keywords for blog content]
|
||||
## MINI CASE SNAPS (3)
|
||||
- [Brand/company], [what they did], [outcome metric], [source URL]
|
||||
|
||||
## CONTENT ANGLES (Top 5)
|
||||
1. [Angle 1: specific unique approach]
|
||||
2. [Angle 2: specific unique approach]
|
||||
3. [Angle 3: specific unique approach]
|
||||
4. [Angle 4: specific unique approach]
|
||||
5. [Angle 5: specific unique approach]
|
||||
## KEYWORDS TO MENTION (Primary + 5 Secondary)
|
||||
- Primary: "{topic}"
|
||||
- Secondary: [5 related keywords]
|
||||
|
||||
## 5 CONTENT ANGLES
|
||||
1. [Angle with audience benefit + why-now]
|
||||
2. [Angle ...]
|
||||
3. [Angle ...]
|
||||
4. [Angle ...]
|
||||
5. [Angle ...]
|
||||
|
||||
## FACT CARD LIST (8)
|
||||
- For each: Quote/claim, source URL, published date, metric/context.
|
||||
|
||||
REQUIREMENTS:
|
||||
- Cite EVERY claim with authoritative source URLs
|
||||
- Use 2024-2025 data when available
|
||||
- Include specific numbers, dates, examples
|
||||
- Focus on actionable blog insights for {target_audience}"""
|
||||
- Every claim MUST include a source URL (authoritative, recent: 2024-2025 preferred).
|
||||
- Use concrete numbers, dates, outcomes; avoid generic advice.
|
||||
- Keep bullets tight and scannable for spoken narration."""
|
||||
return prompt.strip()
|
||||
|
||||
|
||||
@@ -107,57 +106,54 @@ class ComprehensiveResearchStrategy(ResearchStrategy):
|
||||
target_audience: str,
|
||||
config: ResearchConfig
|
||||
) -> str:
|
||||
"""Build comprehensive research prompt with all analysis components."""
|
||||
"""Build comprehensive research prompt with podcast-focused, high-value insights."""
|
||||
date_filter = f"\nDate Focus: {config.date_range.value.replace('_', ' ')}" if config.date_range else ""
|
||||
source_filter = f"\nPriority Sources: {', '.join([s.value for s in config.source_types])}" if config.source_types else ""
|
||||
|
||||
prompt = f"""You are a senior blog content strategist conducting comprehensive research for a {industry} blog targeting {target_audience}.
|
||||
prompt = f"""You are a senior podcast researcher creating deeply sourced talking points for a {industry} audience of {target_audience}.
|
||||
|
||||
Research Topic: "{topic}"{date_filter}{source_filter}
|
||||
|
||||
Provide COMPLETE analysis in this EXACT format:
|
||||
|
||||
## TRENDS AND INSIGHTS (2024-2025)
|
||||
[5-7 trends with specific data, numbers, and source URLs]
|
||||
## WHAT'S CHANGED (2024-2025)
|
||||
[5-7 concise trend bullets with numbers + source URLs]
|
||||
|
||||
## KEY STATISTICS
|
||||
[7-10 statistics with exact numbers, percentages, dates, and source URLs]
|
||||
## PROOF & NUMBERS
|
||||
[10 stats with metric, date, sample size/method, and source URL]
|
||||
|
||||
## EXPERT OPINIONS
|
||||
[4-5 expert quotes with full attribution and source URLs]
|
||||
## EXPERT SIGNALS
|
||||
[5 expert quotes with name, title/company, source URL]
|
||||
|
||||
## RECENT DEVELOPMENTS
|
||||
[5-7 recent news/developments with dates and source URLs]
|
||||
## RECENT MOVES
|
||||
[5-7 news items or launches with dates and source URLs]
|
||||
|
||||
## MARKET ANALYSIS
|
||||
[3-5 market insights with data points and source URLs]
|
||||
## MARKET SNAPSHOTS
|
||||
[3-5 insights with TAM/SAM/SOM or adoption metrics, source URLs]
|
||||
|
||||
## BEST PRACTICES & CASE STUDIES
|
||||
[3-5 examples with specific outcomes/metrics and source URLs]
|
||||
## CASE SNAPS
|
||||
[3-5 cases: who, what they did, outcome metric, source URL]
|
||||
|
||||
## KEYWORD ANALYSIS
|
||||
Primary Keywords: [3 main variations]
|
||||
Secondary Keywords: [7-10 related keywords]
|
||||
Long-Tail Opportunities: [5-7 specific search phrases]
|
||||
## KEYWORD PLAN
|
||||
Primary (3), Secondary (8-10), Long-tail (5-7) with intent hints.
|
||||
|
||||
## COMPETITOR ANALYSIS
|
||||
Top Competitors: [5 competitors with brief descriptions]
|
||||
Content Gaps: [5 topics competitors are missing]
|
||||
Competitive Advantages: [5 unique angles we can own]
|
||||
## COMPETITOR GAPS
|
||||
- Top 5 competitors (URL) + 1-line strength
|
||||
- 5 content gaps we can own
|
||||
- 3 unique angles to differentiate
|
||||
|
||||
## CONTENT ANGLES (Exactly 5)
|
||||
1. [Unique angle with reasoning and target benefit]
|
||||
2. [Unique angle with reasoning and target benefit]
|
||||
3. [Unique angle with reasoning and target benefit]
|
||||
4. [Unique angle with reasoning and target benefit]
|
||||
5. [Unique angle with reasoning and target benefit]
|
||||
## PODCAST-READY ANGLES (5)
|
||||
- Each: Hook, promised takeaway, data or example, source URL.
|
||||
|
||||
## FACT CARD LIST (10)
|
||||
- Each: Quote/claim, source URL, published date, metric/context, suggested angle tag.
|
||||
|
||||
VERIFICATION REQUIREMENTS:
|
||||
- Minimum 2 authoritative sources per major claim
|
||||
- Prioritize: Industry publications > Research papers > News > Blogs
|
||||
- 2024-2025 data strongly preferred
|
||||
- All numbers must include context (timeframe, sample size, methodology)
|
||||
- Every recommendation must be actionable for {target_audience}"""
|
||||
- Minimum 2 authoritative sources per major claim.
|
||||
- Prefer industry reports > research papers > news > blogs.
|
||||
- 2024-2025 data strongly preferred.
|
||||
- All numbers must include timeframe and methodology.
|
||||
- Every bullet must be concise for spoken narration and actionable for {target_audience}."""
|
||||
return prompt.strip()
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,23 @@ class DailyScheduleGenerator:
|
||||
try:
|
||||
logger.info("🚀 Starting daily schedule generation")
|
||||
|
||||
# CRITICAL VALIDATION: Ensure weekly_themes is a list of dictionaries
|
||||
if not isinstance(weekly_themes, list):
|
||||
raise TypeError(f"weekly_themes must be a list, got {type(weekly_themes)}")
|
||||
|
||||
if not weekly_themes:
|
||||
raise ValueError("weekly_themes cannot be empty")
|
||||
|
||||
for i, theme in enumerate(weekly_themes):
|
||||
if not isinstance(theme, dict):
|
||||
raise TypeError(f"weekly_themes[{i}] must be a dictionary, got {type(theme)}. Value: {theme}")
|
||||
|
||||
# Validate required fields
|
||||
if "week_number" not in theme:
|
||||
raise ValueError(f"weekly_themes[{i}] missing required 'week_number' field")
|
||||
|
||||
logger.info(f"✅ Validated {len(weekly_themes)} weekly themes")
|
||||
|
||||
daily_schedules = []
|
||||
current_date = datetime.now()
|
||||
|
||||
@@ -153,12 +170,22 @@ class DailyScheduleGenerator:
|
||||
def _get_weekly_theme(self, weekly_themes: List[Dict], week_number: int) -> Dict:
|
||||
"""Get weekly theme for specific week number."""
|
||||
try:
|
||||
# Additional validation
|
||||
if not isinstance(weekly_themes, list):
|
||||
raise TypeError(f"weekly_themes must be a list, got {type(weekly_themes)}")
|
||||
|
||||
for theme in weekly_themes:
|
||||
if not isinstance(theme, dict):
|
||||
raise TypeError(f"Theme must be a dictionary, got {type(theme)}: {theme}")
|
||||
|
||||
if theme.get("week_number") == week_number:
|
||||
return theme
|
||||
|
||||
# If no theme found, fail with clear error
|
||||
raise ValueError(f"No weekly theme found for week {week_number}")
|
||||
raise ValueError(
|
||||
f"No weekly theme found for week {week_number}. "
|
||||
f"Available weeks: {[t.get('week_number') for t in weekly_themes if isinstance(t, dict)]}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting weekly theme: {str(e)}")
|
||||
@@ -205,9 +232,21 @@ class DailyScheduleGenerator:
|
||||
# Call AI service - NO FALLBACKS
|
||||
ai_response = await self.ai_engine.generate_content_recommendations(analysis_data)
|
||||
|
||||
# Validate AI response - NO FALLBACKS
|
||||
# ENHANCED VALIDATION: Check for unexpected types (including float)
|
||||
if ai_response is None:
|
||||
raise ValueError("AI service returned None")
|
||||
|
||||
if isinstance(ai_response, (int, float, str, bool)):
|
||||
raise TypeError(
|
||||
f"AI service returned primitive type {type(ai_response).__name__}: {ai_response}. "
|
||||
f"Expected list of dictionaries. This indicates an AI service error."
|
||||
)
|
||||
|
||||
if not isinstance(ai_response, list):
|
||||
raise ValueError(f"AI service returned unexpected type: {type(ai_response)}. Expected list, got {type(ai_response)}")
|
||||
raise TypeError(
|
||||
f"AI service returned unexpected type: {type(ai_response).__name__}. "
|
||||
f"Expected list, got {type(ai_response)}. Value: {str(ai_response)[:200]}"
|
||||
)
|
||||
|
||||
if not ai_response:
|
||||
raise ValueError("AI service returned empty list of recommendations")
|
||||
|
||||
@@ -25,6 +25,8 @@ from models.content_asset_models import Base as ContentAssetBase
|
||||
from models.product_marketing_models import Campaign, CampaignProposal, CampaignAsset
|
||||
# Product Asset models (Product Marketing Suite - product assets, not campaigns)
|
||||
from models.product_asset_models import ProductAsset, ProductStyleTemplate, EcommerceExport
|
||||
# Podcast Maker models use SubscriptionBase, but import to ensure models are registered
|
||||
from models.podcast_models import PodcastProject
|
||||
|
||||
# Database configuration
|
||||
DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite:///./alwrity.db')
|
||||
|
||||
@@ -69,13 +69,21 @@ def generate_audio(
|
||||
RuntimeError: If subscription limits are exceeded or user_id is missing.
|
||||
"""
|
||||
try:
|
||||
logger.info("[audio_gen] Starting audio generation")
|
||||
logger.debug(f"[audio_gen] Text length: {len(text)} characters, voice: {voice_id}")
|
||||
# VALIDATION: Check inputs before any processing or API calls
|
||||
if not text or not isinstance(text, str) or len(text.strip()) == 0:
|
||||
raise ValueError("Text input is required and cannot be empty")
|
||||
|
||||
text = text.strip() # Normalize whitespace
|
||||
|
||||
if len(text) > 10000:
|
||||
raise ValueError(f"Text is too long ({len(text)} characters). Maximum is 10,000 characters.")
|
||||
|
||||
# SUBSCRIPTION CHECK - Required and strict enforcement
|
||||
if not user_id:
|
||||
raise RuntimeError("user_id is required for subscription checking. Please provide Clerk user ID.")
|
||||
|
||||
logger.info("[audio_gen] Starting audio generation")
|
||||
logger.debug(f"[audio_gen] Text length: {len(text)} characters, voice: {voice_id}")
|
||||
|
||||
# Calculate cost based on character count (every character is 1 token)
|
||||
# Pricing: $0.05 per 1,000 characters
|
||||
character_count = len(text)
|
||||
@@ -190,8 +198,9 @@ def generate_audio(
|
||||
new_cost = current_cost_before + estimated_cost
|
||||
|
||||
# Use direct SQL UPDATE for dynamic attributes
|
||||
from sqlalchemy import text
|
||||
update_query = text("""
|
||||
# Import sqlalchemy.text with alias to avoid shadowing the 'text' parameter
|
||||
from sqlalchemy import text as sql_text
|
||||
update_query = sql_text("""
|
||||
UPDATE usage_summaries
|
||||
SET audio_calls = :new_calls,
|
||||
audio_cost = :new_cost
|
||||
@@ -210,6 +219,8 @@ def generate_audio(
|
||||
summary.updated_at = datetime.utcnow()
|
||||
|
||||
# Create usage log
|
||||
# Store the text parameter in a local variable before any imports to prevent shadowing
|
||||
text_param = text # Capture function parameter before any potential shadowing
|
||||
usage_log = APIUsageLog(
|
||||
user_id=user_id,
|
||||
provider=APIProvider.AUDIO,
|
||||
@@ -224,7 +235,7 @@ def generate_audio(
|
||||
cost_total=estimated_cost,
|
||||
response_time=0.0,
|
||||
status_code=200,
|
||||
request_size=len(text.encode("utf-8")),
|
||||
request_size=len(text_param.encode("utf-8")), # Use captured parameter
|
||||
response_size=len(audio_bytes),
|
||||
billing_period=current_period,
|
||||
)
|
||||
|
||||
139
backend/services/podcast_service.py
Normal file
139
backend/services/podcast_service.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Podcast Service
|
||||
|
||||
Service layer for managing podcast project persistence.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, and_, or_
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from models.podcast_models import PodcastProject
|
||||
|
||||
|
||||
class PodcastService:
|
||||
"""Service for managing podcast projects."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_project(
|
||||
self,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
idea: str,
|
||||
duration: int,
|
||||
speakers: int,
|
||||
budget_cap: float,
|
||||
**kwargs
|
||||
) -> PodcastProject:
|
||||
"""Create a new podcast project."""
|
||||
project = PodcastProject(
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
idea=idea,
|
||||
duration=duration,
|
||||
speakers=speakers,
|
||||
budget_cap=budget_cap,
|
||||
status="draft",
|
||||
current_step="create",
|
||||
**kwargs
|
||||
)
|
||||
self.db.add(project)
|
||||
self.db.commit()
|
||||
self.db.refresh(project)
|
||||
return project
|
||||
|
||||
def get_project(self, user_id: str, project_id: str) -> Optional[PodcastProject]:
|
||||
"""Get a project by ID, ensuring user ownership."""
|
||||
return self.db.query(PodcastProject).filter(
|
||||
and_(
|
||||
PodcastProject.project_id == project_id,
|
||||
PodcastProject.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
|
||||
def update_project(
|
||||
self,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
**updates
|
||||
) -> Optional[PodcastProject]:
|
||||
"""Update project fields."""
|
||||
project = self.get_project(user_id, project_id)
|
||||
if not project:
|
||||
return None
|
||||
|
||||
# Update fields
|
||||
for key, value in updates.items():
|
||||
if hasattr(project, key):
|
||||
setattr(project, key, value)
|
||||
|
||||
project.updated_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
self.db.refresh(project)
|
||||
return project
|
||||
|
||||
def list_projects(
|
||||
self,
|
||||
user_id: str,
|
||||
status: Optional[str] = None,
|
||||
favorites_only: bool = False,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
order_by: str = "updated_at" # "updated_at" or "created_at"
|
||||
) -> tuple[List[PodcastProject], int]:
|
||||
"""List user's projects with optional filtering."""
|
||||
query = self.db.query(PodcastProject).filter(
|
||||
PodcastProject.user_id == user_id
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if status:
|
||||
query = query.filter(PodcastProject.status == status)
|
||||
|
||||
if favorites_only:
|
||||
query = query.filter(PodcastProject.is_favorite == True)
|
||||
|
||||
# Get total count before pagination
|
||||
total = query.count()
|
||||
|
||||
# Apply ordering
|
||||
if order_by == "created_at":
|
||||
query = query.order_by(desc(PodcastProject.created_at))
|
||||
else:
|
||||
query = query.order_by(desc(PodcastProject.updated_at))
|
||||
|
||||
# Apply pagination
|
||||
projects = query.offset(offset).limit(limit).all()
|
||||
|
||||
return projects, total
|
||||
|
||||
def delete_project(self, user_id: str, project_id: str) -> bool:
|
||||
"""Delete a project."""
|
||||
project = self.get_project(user_id, project_id)
|
||||
if not project:
|
||||
return False
|
||||
|
||||
self.db.delete(project)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def toggle_favorite(self, user_id: str, project_id: str) -> Optional[PodcastProject]:
|
||||
"""Toggle favorite status of a project."""
|
||||
project = self.get_project(user_id, project_id)
|
||||
if not project:
|
||||
return None
|
||||
|
||||
project.is_favorite = not project.is_favorite
|
||||
project.updated_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
self.db.refresh(project)
|
||||
return project
|
||||
|
||||
def update_status(self, user_id: str, project_id: str, status: str) -> Optional[PodcastProject]:
|
||||
"""Update project status."""
|
||||
return self.update_project(user_id, project_id, status=status)
|
||||
|
||||
@@ -8,6 +8,8 @@ from typing import Any, Dict, List
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
from .base import StoryServiceBase
|
||||
|
||||
|
||||
|
||||
@@ -545,6 +545,188 @@ def validate_video_generation_operations(
|
||||
)
|
||||
|
||||
|
||||
def validate_scene_animation_operation(
|
||||
pricing_service: PricingService,
|
||||
user_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Validate the per-scene animation workflow before API calls.
|
||||
"""
|
||||
try:
|
||||
operations_to_validate = [
|
||||
{
|
||||
'provider': APIProvider.VIDEO,
|
||||
'tokens_requested': 0,
|
||||
'actual_provider_name': 'wavespeed',
|
||||
'operation_type': 'scene_animation',
|
||||
}
|
||||
]
|
||||
|
||||
can_proceed, message, error_details = pricing_service.check_comprehensive_limits(
|
||||
user_id=user_id,
|
||||
operations=operations_to_validate,
|
||||
)
|
||||
|
||||
if not can_proceed:
|
||||
logger.error(f"[Pre-flight Validator] Scene animation blocked for user {user_id}: {message}")
|
||||
usage_info = error_details.get('usage_info', {}) if error_details else {}
|
||||
provider = usage_info.get('provider', 'video') if usage_info else 'video'
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail={
|
||||
'error': message,
|
||||
'message': message,
|
||||
'provider': provider,
|
||||
'usage_info': usage_info if usage_info else error_details,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"[Pre-flight Validator] ✅ Scene animation validated for user {user_id}")
|
||||
# Validation passed - no return needed (function raises HTTPException if validation fails)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Pre-flight Validator] Error validating scene animation: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
'error': f"Failed to validate scene animation: {str(e)}",
|
||||
'message': f"Failed to validate scene animation: {str(e)}"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def validate_image_control_operations(
|
||||
pricing_service: PricingService,
|
||||
user_id: str,
|
||||
num_images: int = 1
|
||||
) -> None:
|
||||
"""
|
||||
Validate image control operations (sketch-to-image, structure control, style transfer) before making API calls.
|
||||
|
||||
Control operations use Stability AI for image generation with control inputs, so they use
|
||||
the same validation as image generation operations.
|
||||
|
||||
Args:
|
||||
pricing_service: PricingService instance
|
||||
user_id: User ID for subscription checking
|
||||
num_images: Number of images to generate (for multiple variations)
|
||||
|
||||
Returns:
|
||||
None - raises HTTPException with 429 status if validation fails
|
||||
"""
|
||||
try:
|
||||
# Control operations use Stability AI, same as image generation
|
||||
operations_to_validate = [
|
||||
{
|
||||
'provider': APIProvider.STABILITY,
|
||||
'tokens_requested': 0,
|
||||
'actual_provider_name': 'stability',
|
||||
'operation_type': 'image_generation' # Control ops use image generation limits
|
||||
}
|
||||
for _ in range(num_images)
|
||||
]
|
||||
|
||||
logger.info(f"[Pre-flight Validator] 🚀 Validating {num_images} image control operation(s) for user {user_id}")
|
||||
|
||||
can_proceed, message, error_details = pricing_service.check_comprehensive_limits(
|
||||
user_id=user_id,
|
||||
operations=operations_to_validate
|
||||
)
|
||||
|
||||
if not can_proceed:
|
||||
logger.error(f"[Pre-flight Validator] Image control blocked for user {user_id}: {message}")
|
||||
|
||||
usage_info = error_details.get('usage_info', {}) if error_details else {}
|
||||
provider = usage_info.get('provider', 'stability') if usage_info else 'stability'
|
||||
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail={
|
||||
'error': message,
|
||||
'message': message,
|
||||
'provider': provider,
|
||||
'usage_info': usage_info if usage_info else error_details
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"[Pre-flight Validator] ✅ Image control validated for user {user_id}")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Pre-flight Validator] Error validating image control: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
'error': f"Failed to validate image control: {str(e)}",
|
||||
'message': f"Failed to validate image control: {str(e)}"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def validate_video_generation_operations(
|
||||
pricing_service: PricingService,
|
||||
user_id: str
|
||||
) -> None:
|
||||
"""
|
||||
Validate video generation operation before making API calls.
|
||||
|
||||
Args:
|
||||
pricing_service: PricingService instance
|
||||
user_id: User ID for subscription checking
|
||||
|
||||
Returns:
|
||||
None - raises HTTPException with 429 status if validation fails
|
||||
"""
|
||||
try:
|
||||
operations_to_validate = [
|
||||
{
|
||||
'provider': APIProvider.VIDEO,
|
||||
'tokens_requested': 0,
|
||||
'actual_provider_name': 'video',
|
||||
'operation_type': 'video_generation'
|
||||
}
|
||||
]
|
||||
|
||||
can_proceed, message, error_details = pricing_service.check_comprehensive_limits(
|
||||
user_id=user_id,
|
||||
operations=operations_to_validate
|
||||
)
|
||||
|
||||
if not can_proceed:
|
||||
logger.error(f"[Pre-flight Validator] Video generation blocked for user {user_id}: {message}")
|
||||
|
||||
usage_info = error_details.get('usage_info', {}) if error_details else {}
|
||||
provider = usage_info.get('provider', 'video') if usage_info else 'video'
|
||||
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail={
|
||||
'error': message,
|
||||
'message': message,
|
||||
'provider': provider,
|
||||
'usage_info': usage_info if usage_info else error_details
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"[Pre-flight Validator] ✅ Video generation validated for user {user_id}")
|
||||
# Validation passed - no return needed (function raises HTTPException if validation fails)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Pre-flight Validator] Error validating video generation: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
'error': f"Failed to validate video generation: {str(e)}",
|
||||
'message': f"Failed to validate video generation: {str(e)}"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def validate_scene_animation_operation(
|
||||
pricing_service: PricingService,
|
||||
user_id: str,
|
||||
@@ -593,4 +775,79 @@ def validate_scene_animation_operation(
|
||||
'error': f"Failed to validate scene animation: {str(e)}",
|
||||
'message': f"Failed to validate scene animation: {str(e)}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def validate_calendar_generation_operations(
|
||||
pricing_service: PricingService,
|
||||
user_id: str,
|
||||
gpt_provider: str = "google"
|
||||
) -> None:
|
||||
"""
|
||||
Validate calendar generation operations before making API calls.
|
||||
|
||||
Args:
|
||||
pricing_service: PricingService instance
|
||||
user_id: User ID for subscription checking
|
||||
gpt_provider: GPT provider from env var (defaults to "google")
|
||||
|
||||
Returns:
|
||||
None - raises HTTPException with 429 status if validation fails
|
||||
"""
|
||||
try:
|
||||
# Determine actual provider for LLM calls based on GPT_PROVIDER env var
|
||||
gpt_provider_lower = gpt_provider.lower()
|
||||
if gpt_provider_lower == "huggingface":
|
||||
llm_provider_enum = APIProvider.MISTRAL
|
||||
llm_provider_name = "huggingface"
|
||||
else:
|
||||
llm_provider_enum = APIProvider.GEMINI
|
||||
llm_provider_name = "gemini"
|
||||
|
||||
# Estimate tokens for 12-step process
|
||||
# This is a heavy operation involving multiple steps and analysis
|
||||
operations_to_validate = [
|
||||
{
|
||||
'provider': llm_provider_enum,
|
||||
'tokens_requested': 20000, # Conservative estimate for full calendar generation
|
||||
'actual_provider_name': llm_provider_name,
|
||||
'operation_type': 'calendar_generation'
|
||||
}
|
||||
]
|
||||
|
||||
logger.info(f"[Pre-flight Validator] 🚀 Validating Calendar Generation for user {user_id}")
|
||||
|
||||
can_proceed, message, error_details = pricing_service.check_comprehensive_limits(
|
||||
user_id=user_id,
|
||||
operations=operations_to_validate
|
||||
)
|
||||
|
||||
if not can_proceed:
|
||||
usage_info = error_details.get('usage_info', {}) if error_details else {}
|
||||
provider = usage_info.get('provider', llm_provider_name) if usage_info else llm_provider_name
|
||||
|
||||
logger.warning(f"[Pre-flight Validator] Calendar generation blocked for user {user_id}: {message}")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail={
|
||||
'error': message,
|
||||
'message': message,
|
||||
'provider': provider,
|
||||
'usage_info': usage_info if usage_info else error_details
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"[Pre-flight Validator] ✅ Calendar Generation validated for user {user_id}")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Pre-flight Validator] Error validating calendar generation: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
'error': f"Failed to validate calendar generation: {str(e)}",
|
||||
'message': f"Failed to validate calendar generation: {str(e)}"
|
||||
}
|
||||
)
|
||||
@@ -637,4 +637,260 @@ class WaveSpeedClient:
|
||||
status_code=502,
|
||||
detail="Failed to fetch generated audio from WaveSpeed URL",
|
||||
)
|
||||
|
||||
def submit_text_to_video(
|
||||
self,
|
||||
model_path: str,
|
||||
payload: Dict[str, Any],
|
||||
timeout: int = 60,
|
||||
) -> str:
|
||||
"""
|
||||
Submit a text-to-video generation request to WaveSpeed.
|
||||
|
||||
Args:
|
||||
model_path: Model path (e.g., "alibaba/wan-2.5/text-to-video")
|
||||
payload: Request payload with prompt, resolution, duration, optional audio
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Prediction ID for polling
|
||||
"""
|
||||
url = f"{self.BASE_URL}/{model_path}"
|
||||
logger.info(f"[WaveSpeed] Submitting text-to-video request to {url}")
|
||||
response = requests.post(url, headers=self._headers(), json=payload, timeout=timeout)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"[WaveSpeed] Text-to-video submission failed: {response.status_code} {response.text}")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "WaveSpeed text-to-video submission failed",
|
||||
"status_code": response.status_code,
|
||||
"response": response.text,
|
||||
},
|
||||
)
|
||||
|
||||
data = response.json().get("data")
|
||||
if not data or "id" not in data:
|
||||
logger.error(f"[WaveSpeed] Unexpected text-to-video response: {response.text}")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={"error": "WaveSpeed response missing prediction id"},
|
||||
)
|
||||
|
||||
prediction_id = data["id"]
|
||||
logger.info(f"[WaveSpeed] Submitted text-to-video request: {prediction_id}")
|
||||
return prediction_id
|
||||
|
||||
def generate_text_video(
|
||||
self,
|
||||
prompt: str,
|
||||
resolution: str = "720p", # 480p, 720p, 1080p
|
||||
duration: int = 5, # 5 or 10 seconds
|
||||
audio_base64: Optional[str] = None, # Optional audio for lip-sync
|
||||
negative_prompt: Optional[str] = None,
|
||||
seed: Optional[int] = None,
|
||||
enable_prompt_expansion: bool = True,
|
||||
enable_sync_mode: bool = False,
|
||||
timeout: int = 180,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate video from text prompt using WAN 2.5 text-to-video.
|
||||
|
||||
Args:
|
||||
prompt: Text prompt describing the video
|
||||
resolution: Output resolution (480p, 720p, 1080p)
|
||||
duration: Video duration in seconds (5 or 10)
|
||||
audio_base64: Optional audio file (wav/mp3, 3-30s, ≤15MB) for lip-sync
|
||||
negative_prompt: Optional negative prompt
|
||||
seed: Optional random seed for reproducibility
|
||||
enable_prompt_expansion: Enable prompt optimizer
|
||||
enable_sync_mode: If True, wait for result and return it directly
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Dictionary with video bytes, metadata, and cost
|
||||
"""
|
||||
model_path = "alibaba/wan-2.5/text-to-video"
|
||||
|
||||
# Validate resolution
|
||||
valid_resolutions = ["480p", "720p", "1080p"]
|
||||
if resolution not in valid_resolutions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid resolution: {resolution}. Must be one of: {valid_resolutions}"
|
||||
)
|
||||
|
||||
# Validate duration
|
||||
if duration not in [5, 10]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Duration must be 5 or 10 seconds"
|
||||
)
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"prompt": prompt,
|
||||
"resolution": resolution,
|
||||
"duration": duration,
|
||||
"enable_prompt_expansion": enable_prompt_expansion,
|
||||
"enable_sync_mode": enable_sync_mode, # Add sync mode to payload
|
||||
}
|
||||
|
||||
# Add optional audio
|
||||
if audio_base64:
|
||||
payload["audio"] = audio_base64
|
||||
|
||||
# Add optional parameters
|
||||
if negative_prompt:
|
||||
payload["negative_prompt"] = negative_prompt
|
||||
if seed is not None:
|
||||
payload["seed"] = seed
|
||||
|
||||
# Submit request
|
||||
logger.info(
|
||||
f"[WaveSpeed] Generating text-to-video: resolution={resolution}, "
|
||||
f"duration={duration}s, prompt_length={len(prompt)}, sync_mode={enable_sync_mode}"
|
||||
)
|
||||
|
||||
# For sync mode, submit and get result directly
|
||||
if enable_sync_mode:
|
||||
url = f"{self.BASE_URL}/{model_path}"
|
||||
response = requests.post(url, headers=self._headers(), json=payload, timeout=timeout)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"[WaveSpeed] Text-to-video submission failed: {response.status_code} {response.text}")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "WaveSpeed text-to-video submission failed",
|
||||
"status_code": response.status_code,
|
||||
"response": response.text[:500],
|
||||
},
|
||||
)
|
||||
|
||||
response_json = response.json()
|
||||
data = response_json.get("data") or response_json
|
||||
|
||||
# In sync mode, result should be directly in outputs
|
||||
outputs = data.get("outputs") or []
|
||||
if not outputs:
|
||||
logger.error(f"[WaveSpeed] No outputs in sync mode response: {response.text[:500]}")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="WaveSpeed text-to-video returned no outputs in sync mode",
|
||||
)
|
||||
|
||||
# Extract video URL from outputs
|
||||
video_url = outputs[0]
|
||||
if not isinstance(video_url, str) or not video_url.startswith("http"):
|
||||
logger.error(f"[WaveSpeed] Invalid video URL format in sync mode: {video_url}")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Invalid video URL format: {video_url}",
|
||||
)
|
||||
|
||||
# Download video
|
||||
logger.info(f"[WaveSpeed] Downloading video from sync mode URL: {video_url}")
|
||||
video_response = requests.get(video_url, timeout=180)
|
||||
|
||||
if video_response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "Failed to download WAN 2.5 video from sync mode",
|
||||
"status_code": video_response.status_code,
|
||||
"response": video_response.text[:200],
|
||||
}
|
||||
)
|
||||
|
||||
video_bytes = video_response.content
|
||||
prediction_id = data.get("id", "sync_mode")
|
||||
metadata = data.get("metadata") or {}
|
||||
# video_url is already set above for sync mode
|
||||
else:
|
||||
# Async mode - submit and poll
|
||||
prediction_id = self.submit_text_to_video(model_path, payload, timeout=timeout)
|
||||
|
||||
# Poll for completion
|
||||
try:
|
||||
result = self.poll_until_complete(
|
||||
prediction_id,
|
||||
timeout_seconds=timeout,
|
||||
interval_seconds=2.0
|
||||
)
|
||||
except HTTPException as e:
|
||||
detail = e.detail or {}
|
||||
if isinstance(detail, dict):
|
||||
detail.setdefault("prediction_id", prediction_id)
|
||||
detail.setdefault("resume_available", True)
|
||||
raise HTTPException(status_code=e.status_code, detail=detail)
|
||||
|
||||
# Extract video URL
|
||||
outputs = result.get("outputs") or []
|
||||
if not outputs:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="WAN 2.5 text-to-video completed but returned no outputs"
|
||||
)
|
||||
|
||||
video_url = outputs[0]
|
||||
if not isinstance(video_url, str) or not video_url.startswith("http"):
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Invalid video URL format: {video_url}"
|
||||
)
|
||||
|
||||
# Download video
|
||||
logger.info(f"[WaveSpeed] Downloading video from: {video_url}")
|
||||
video_response = requests.get(video_url, timeout=180)
|
||||
|
||||
if video_response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "Failed to download WAN 2.5 video",
|
||||
"status_code": video_response.status_code,
|
||||
"response": video_response.text[:200],
|
||||
}
|
||||
)
|
||||
|
||||
video_bytes = video_response.content
|
||||
metadata = result.get("metadata") or {}
|
||||
|
||||
# Calculate cost (same pricing as image-to-video)
|
||||
pricing = {
|
||||
"480p": 0.05,
|
||||
"720p": 0.10,
|
||||
"1080p": 0.15,
|
||||
}
|
||||
cost = pricing.get(resolution, 0.10) * duration
|
||||
|
||||
# Get video dimensions
|
||||
resolution_dims = {
|
||||
"480p": (854, 480),
|
||||
"720p": (1280, 720),
|
||||
"1080p": (1920, 1080),
|
||||
}
|
||||
width, height = resolution_dims.get(resolution, (1280, 720))
|
||||
|
||||
logger.info(
|
||||
f"[WaveSpeed] ✅ Generated text-to-video: {len(video_bytes)} bytes, "
|
||||
f"resolution={resolution}, duration={duration}s, cost=${cost:.2f}"
|
||||
)
|
||||
|
||||
return {
|
||||
"video_bytes": video_bytes,
|
||||
"prompt": prompt,
|
||||
"duration": float(duration),
|
||||
"model_name": "alibaba/wan-2.5/text-to-video",
|
||||
"cost": cost,
|
||||
"provider": "wavespeed",
|
||||
"source_video_url": video_url,
|
||||
"prediction_id": prediction_id,
|
||||
"resolution": resolution,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
2
backend/services/youtube/__init__.py
Normal file
2
backend/services/youtube/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""YouTube Creator Studio services."""
|
||||
|
||||
358
backend/services/youtube/planner.py
Normal file
358
backend/services/youtube/planner.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""
|
||||
YouTube Video Planner Service
|
||||
|
||||
Generates video plans, outlines, and insights using AI with persona integration.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
from loguru import logger
|
||||
from fastapi import HTTPException
|
||||
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
logger = get_service_logger("youtube.planner")
|
||||
|
||||
|
||||
class YouTubePlannerService:
|
||||
"""Service for planning YouTube videos with AI assistance."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the planner service."""
|
||||
logger.info("[YouTubePlanner] Service initialized")
|
||||
|
||||
def generate_video_plan(
|
||||
self,
|
||||
user_idea: str,
|
||||
duration_type: str, # "shorts", "medium", "long"
|
||||
persona_data: Optional[Dict[str, Any]] = None,
|
||||
reference_image_description: Optional[str] = None,
|
||||
source_content_id: Optional[str] = None, # For blog/story conversion
|
||||
source_content_type: Optional[str] = None, # "blog", "story"
|
||||
user_id: str = None,
|
||||
include_scenes: bool = False, # For shorts: combine plan + scenes in one call
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a comprehensive video plan from user input.
|
||||
|
||||
Args:
|
||||
user_idea: User's video idea or topic
|
||||
duration_type: "shorts" (≤60s), "medium" (1-4min), "long" (4-10min)
|
||||
persona_data: Optional persona data for tone/style
|
||||
reference_image_description: Optional description of reference image
|
||||
source_content_id: Optional ID of source content (blog/story)
|
||||
source_content_type: Type of source content
|
||||
user_id: Clerk user ID for subscription checking
|
||||
|
||||
Returns:
|
||||
Dictionary with video plan, outline, insights, and metadata
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"[YouTubePlanner] Generating plan: idea={user_idea[:50]}..., "
|
||||
f"duration={duration_type}, user={user_id}"
|
||||
)
|
||||
|
||||
# Build persona context
|
||||
persona_context = self._build_persona_context(persona_data)
|
||||
|
||||
# Build duration context
|
||||
duration_context = self._get_duration_context(duration_type)
|
||||
|
||||
# Build source content context if provided
|
||||
source_context = ""
|
||||
if source_content_id and source_content_type:
|
||||
source_context = f"""
|
||||
**Source Content:**
|
||||
- Type: {source_content_type}
|
||||
- ID: {source_content_id}
|
||||
- Note: This video should be based on the existing {source_content_type} content.
|
||||
"""
|
||||
|
||||
# Build reference image context
|
||||
image_context = ""
|
||||
if reference_image_description:
|
||||
image_context = f"""
|
||||
**Reference Image:**
|
||||
{reference_image_description}
|
||||
- Use this as visual inspiration for the video
|
||||
"""
|
||||
|
||||
# Generate comprehensive video plan
|
||||
planning_prompt = f"""You are an expert YouTube content strategist. Create a comprehensive video plan based on the user's idea.
|
||||
|
||||
**User's Video Idea:**
|
||||
{user_idea}
|
||||
|
||||
**Video Duration Type:**
|
||||
{duration_type} ({duration_context['description']})
|
||||
|
||||
**Duration Guidelines:**
|
||||
- Target length: {duration_context['target_seconds']} seconds
|
||||
- Hook duration: {duration_context['hook_seconds']} seconds
|
||||
- Main content: {duration_context['main_seconds']} seconds
|
||||
- CTA duration: {duration_context['cta_seconds']} seconds
|
||||
- Maximum scenes: {duration_context['max_scenes']} (for shorts, keep 2-4 scenes total)
|
||||
|
||||
{persona_context}
|
||||
|
||||
{source_context}
|
||||
|
||||
{image_context}
|
||||
|
||||
**Your Task:**
|
||||
Create a detailed video plan that includes:
|
||||
|
||||
1. **Video Summary**: A 2-3 sentence overview of what the video will cover
|
||||
2. **Target Audience**: Who this video is for
|
||||
3. **Video Goal**: Primary objective (educate, entertain, sell, inspire, etc.)
|
||||
4. **Key Message**: The main takeaway viewers should remember
|
||||
5. **Hook Strategy**: Attention-grabbing opening (first {duration_context['hook_seconds']} seconds)
|
||||
6. **Content Outline**: High-level structure with 3-5 main sections
|
||||
7. **Call-to-Action**: Clear CTA that fits the video goal
|
||||
8. **Visual Style**: Recommended visual approach (cinematic, tutorial, vlog, etc.)
|
||||
9. **Tone**: Recommended tone (professional, casual, energetic, etc.)
|
||||
10. **SEO Keywords**: 5-7 relevant keywords for YouTube SEO
|
||||
|
||||
**Format your response as JSON:**
|
||||
{{
|
||||
"video_summary": "...",
|
||||
"target_audience": "...",
|
||||
"video_goal": "...",
|
||||
"key_message": "...",
|
||||
"hook_strategy": "...",
|
||||
"content_outline": [
|
||||
{{"section": "Section 1", "description": "...", "duration_estimate": 30}},
|
||||
{{"section": "Section 2", "description": "...", "duration_estimate": 45}}
|
||||
],
|
||||
"call_to_action": "...",
|
||||
"visual_style": "...",
|
||||
"tone": "...",
|
||||
"seo_keywords": ["keyword1", "keyword2", ...]
|
||||
}}
|
||||
|
||||
Make sure the content outline fits within the {duration_type} duration constraints.
|
||||
"""
|
||||
|
||||
system_prompt = (
|
||||
"You are an expert YouTube content strategist specializing in creating "
|
||||
"engaging, well-structured video plans. Your plans are data-driven, "
|
||||
"audience-focused, and optimized for YouTube's algorithm."
|
||||
)
|
||||
|
||||
# For shorts, combine plan + scenes in one call to save API calls
|
||||
if include_scenes and duration_type == "shorts":
|
||||
planning_prompt += f"""
|
||||
|
||||
**IMPORTANT: Since this is a SHORTS video, also generate the complete scene breakdown in the same response.**
|
||||
|
||||
**Additional Task - Generate Detailed Scenes:**
|
||||
Create detailed scenes (up to {duration_context['max_scenes']} scenes) that include:
|
||||
1. Scene number and title
|
||||
2. Narration text (what will be spoken) - keep it concise for shorts
|
||||
3. Visual description (what viewers will see)
|
||||
4. Duration estimate (2-8 seconds each)
|
||||
5. Emphasis tags (hook, main_content, transition, cta)
|
||||
|
||||
**Scene Format:**
|
||||
Each scene should be detailed enough for video generation. Total duration must fit within {duration_context['target_seconds']} seconds.
|
||||
|
||||
**Update JSON structure to include "scenes" array:**
|
||||
Add a "scenes" field with the complete scene breakdown.
|
||||
"""
|
||||
|
||||
json_struct = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"video_summary": {"type": "string"},
|
||||
"target_audience": {"type": "string"},
|
||||
"video_goal": {"type": "string"},
|
||||
"key_message": {"type": "string"},
|
||||
"hook_strategy": {"type": "string"},
|
||||
"content_outline": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"section": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"duration_estimate": {"type": "number"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"call_to_action": {"type": "string"},
|
||||
"visual_style": {"type": "string"},
|
||||
"tone": {"type": "string"},
|
||||
"seo_keywords": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
},
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scene_number": {"type": "number"},
|
||||
"title": {"type": "string"},
|
||||
"narration": {"type": "string"},
|
||||
"visual_description": {"type": "string"},
|
||||
"duration_estimate": {"type": "number"},
|
||||
"emphasis": {"type": "string"},
|
||||
"visual_cues": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"scene_number", "title", "narration", "visual_description",
|
||||
"duration_estimate", "emphasis"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"video_summary", "target_audience", "video_goal", "key_message",
|
||||
"hook_strategy", "content_outline", "call_to_action",
|
||||
"visual_style", "tone", "seo_keywords", "scenes"
|
||||
]
|
||||
}
|
||||
else:
|
||||
json_struct = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"video_summary": {"type": "string"},
|
||||
"target_audience": {"type": "string"},
|
||||
"video_goal": {"type": "string"},
|
||||
"key_message": {"type": "string"},
|
||||
"hook_strategy": {"type": "string"},
|
||||
"content_outline": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"section": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"duration_estimate": {"type": "number"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"call_to_action": {"type": "string"},
|
||||
"visual_style": {"type": "string"},
|
||||
"tone": {"type": "string"},
|
||||
"seo_keywords": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"video_summary", "target_audience", "video_goal", "key_message",
|
||||
"hook_strategy", "content_outline", "call_to_action",
|
||||
"visual_style", "tone", "seo_keywords"
|
||||
]
|
||||
}
|
||||
|
||||
# Generate plan using LLM
|
||||
response = llm_text_gen(
|
||||
prompt=planning_prompt,
|
||||
system_prompt=system_prompt,
|
||||
user_id=user_id,
|
||||
json_struct=json_struct
|
||||
)
|
||||
|
||||
# Parse response (handle both dict and JSON string)
|
||||
if isinstance(response, dict):
|
||||
plan_data = response
|
||||
else:
|
||||
import json
|
||||
plan_data = json.loads(response)
|
||||
|
||||
# Add metadata
|
||||
plan_data["duration_type"] = duration_type
|
||||
plan_data["duration_metadata"] = duration_context
|
||||
plan_data["user_idea"] = user_idea
|
||||
|
||||
# If scenes were included, mark them for scene builder
|
||||
if include_scenes and duration_type == "shorts" and "scenes" in plan_data:
|
||||
plan_data["_scenes_included"] = True
|
||||
logger.info(
|
||||
f"[YouTubePlanner] ✅ Plan + {len(plan_data.get('scenes', []))} scenes "
|
||||
f"generated in 1 AI call (optimized for shorts)"
|
||||
)
|
||||
else:
|
||||
if include_scenes and duration_type == "shorts":
|
||||
# LLM did not return scenes; downstream will regenerate
|
||||
plan_data["_scenes_included"] = False
|
||||
logger.warning(
|
||||
"[YouTubePlanner] Shorts optimization requested but no scenes returned; "
|
||||
"scene builder will generate scenes separately."
|
||||
)
|
||||
logger.info(f"[YouTubePlanner] ✅ Plan generated successfully")
|
||||
|
||||
return plan_data
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[YouTubePlanner] Error generating plan: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to generate video plan: {str(e)}"
|
||||
)
|
||||
|
||||
def _build_persona_context(self, persona_data: Optional[Dict[str, Any]]) -> str:
|
||||
"""Build persona context string for prompts."""
|
||||
if not persona_data:
|
||||
return """
|
||||
**Persona Context:**
|
||||
- Using default professional tone
|
||||
- No specific persona constraints
|
||||
"""
|
||||
|
||||
core_persona = persona_data.get("core_persona", {})
|
||||
tone = core_persona.get("tone", "professional")
|
||||
voice = core_persona.get("voice_characteristics", {})
|
||||
|
||||
return f"""
|
||||
**Persona Context:**
|
||||
- Tone: {tone}
|
||||
- Voice Style: {voice.get('style', 'professional')}
|
||||
- Communication Style: {voice.get('communication_style', 'clear and direct')}
|
||||
- Brand Values: {core_persona.get('core_belief', 'value-driven content')}
|
||||
- Use this persona to guide the video's tone, style, and messaging approach.
|
||||
"""
|
||||
|
||||
def _get_duration_context(self, duration_type: str) -> Dict[str, Any]:
|
||||
"""Get duration-specific context and constraints."""
|
||||
contexts = {
|
||||
"shorts": {
|
||||
"description": "YouTube Shorts (15-60 seconds)",
|
||||
"target_seconds": 30,
|
||||
"hook_seconds": 3,
|
||||
"main_seconds": 24,
|
||||
"cta_seconds": 3,
|
||||
# Keep scenes tight for shorts to control cost and pacing
|
||||
"max_scenes": 4,
|
||||
"scene_duration_range": (2, 8)
|
||||
},
|
||||
"medium": {
|
||||
"description": "Medium-length video (1-4 minutes)",
|
||||
"target_seconds": 150, # 2.5 minutes
|
||||
"hook_seconds": 10,
|
||||
"main_seconds": 130,
|
||||
"cta_seconds": 10,
|
||||
"max_scenes": 12,
|
||||
"scene_duration_range": (5, 15)
|
||||
},
|
||||
"long": {
|
||||
"description": "Long-form video (4-10 minutes)",
|
||||
"target_seconds": 420, # 7 minutes
|
||||
"hook_seconds": 15,
|
||||
"main_seconds": 380,
|
||||
"cta_seconds": 25,
|
||||
"max_scenes": 20,
|
||||
"scene_duration_range": (10, 30)
|
||||
}
|
||||
}
|
||||
|
||||
return contexts.get(duration_type, contexts["medium"])
|
||||
|
||||
412
backend/services/youtube/renderer.py
Normal file
412
backend/services/youtube/renderer.py
Normal file
@@ -0,0 +1,412 @@
|
||||
"""
|
||||
YouTube Video Renderer Service
|
||||
|
||||
Handles video rendering using WAN 2.5 text-to-video and audio generation.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pathlib import Path
|
||||
import base64
|
||||
import uuid
|
||||
import requests
|
||||
from loguru import logger
|
||||
from fastapi import HTTPException
|
||||
|
||||
from services.wavespeed.client import WaveSpeedClient
|
||||
from services.llm_providers.main_audio_generation import generate_audio
|
||||
from services.story_writer.video_generation_service import StoryVideoGenerationService
|
||||
from services.subscription import PricingService
|
||||
from services.subscription.preflight_validator import validate_scene_animation_operation
|
||||
from services.llm_providers.main_video_generation import track_video_usage
|
||||
from utils.logger_utils import get_service_logger
|
||||
from utils.asset_tracker import save_asset_to_library
|
||||
|
||||
logger = get_service_logger("youtube.renderer")
|
||||
|
||||
|
||||
class YouTubeVideoRendererService:
|
||||
"""Service for rendering YouTube videos from scenes."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the renderer service."""
|
||||
self.wavespeed_client = WaveSpeedClient()
|
||||
|
||||
# Video output directory
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
self.output_dir = base_dir / "youtube_videos"
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(f"[YouTubeRenderer] Initialized with output directory: {self.output_dir}")
|
||||
|
||||
def render_scene_video(
|
||||
self,
|
||||
scene: Dict[str, Any],
|
||||
video_plan: Dict[str, Any],
|
||||
user_id: str,
|
||||
resolution: str = "720p",
|
||||
generate_audio_enabled: bool = True,
|
||||
voice_id: str = "Wise_Woman",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Render a single scene into a video.
|
||||
|
||||
Args:
|
||||
scene: Scene data with narration and visual prompts
|
||||
video_plan: Original video plan for context
|
||||
user_id: Clerk user ID
|
||||
resolution: Video resolution (480p, 720p, 1080p)
|
||||
generate_audio: Whether to generate narration audio
|
||||
voice_id: Voice ID for audio generation
|
||||
|
||||
Returns:
|
||||
Dictionary with video metadata, bytes, and cost
|
||||
"""
|
||||
try:
|
||||
scene_number = scene.get("scene_number", 1)
|
||||
narration = scene.get("narration", "").strip()
|
||||
visual_prompt = (scene.get("enhanced_visual_prompt") or scene.get("visual_prompt", "")).strip()
|
||||
duration_estimate = scene.get("duration_estimate", 5)
|
||||
|
||||
# VALIDATION: Check inputs before making expensive API calls
|
||||
if not visual_prompt:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": f"Scene {scene_number} has no visual prompt",
|
||||
"scene_number": scene_number,
|
||||
"message": "Visual prompt is required for video generation",
|
||||
"user_action": "Please add a visual description for this scene before rendering.",
|
||||
}
|
||||
)
|
||||
|
||||
if len(visual_prompt) < 10:
|
||||
logger.warning(
|
||||
f"[YouTubeRenderer] Scene {scene_number} has very short visual prompt "
|
||||
f"({len(visual_prompt)} chars), may result in poor quality"
|
||||
)
|
||||
|
||||
# Clamp duration to valid WAN 2.5 values (5 or 10 seconds)
|
||||
duration = 5 if duration_estimate <= 7 else 10
|
||||
|
||||
logger.info(
|
||||
f"[YouTubeRenderer] Rendering scene {scene_number}: "
|
||||
f"resolution={resolution}, duration={duration}s, prompt_length={len(visual_prompt)}"
|
||||
)
|
||||
|
||||
# Generate audio if requested - only if narration is not empty
|
||||
audio_base64 = None
|
||||
if generate_audio_enabled and narration and len(narration.strip()) > 0:
|
||||
try:
|
||||
audio_result = generate_audio(
|
||||
text=narration,
|
||||
voice_id=voice_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
# generate_audio may return raw bytes or AudioGenerationResult
|
||||
audio_bytes = audio_result.audio_bytes if hasattr(audio_result, "audio_bytes") else audio_result
|
||||
# Convert to base64 (just the base64 string, not data URI)
|
||||
audio_base64 = base64.b64encode(audio_bytes).decode('utf-8')
|
||||
logger.info(f"[YouTubeRenderer] Generated audio for scene {scene_number}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[YouTubeRenderer] Audio generation failed: {e}, continuing without audio")
|
||||
|
||||
# VALIDATION: Final check before expensive video API call
|
||||
if not visual_prompt or len(visual_prompt.strip()) < 5:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": f"Scene {scene_number} has invalid visual prompt",
|
||||
"scene_number": scene_number,
|
||||
"message": "Visual prompt must be at least 5 characters",
|
||||
"user_action": "Please provide a valid visual description for this scene.",
|
||||
}
|
||||
)
|
||||
|
||||
# Generate video using WAN 2.5 text-to-video
|
||||
# This is the expensive API call - all validation should be done before this
|
||||
# Use sync mode to wait for result directly (prevents timeout issues)
|
||||
try:
|
||||
video_result = self.wavespeed_client.generate_text_video(
|
||||
prompt=visual_prompt,
|
||||
resolution=resolution,
|
||||
duration=duration,
|
||||
audio_base64=audio_base64, # Optional: enables lip-sync if provided
|
||||
enable_prompt_expansion=True,
|
||||
enable_sync_mode=True, # Use sync mode to wait for result directly
|
||||
timeout=600, # Increased timeout for sync mode (10 minutes)
|
||||
)
|
||||
except requests.exceptions.Timeout as e:
|
||||
logger.error(f"[YouTubeRenderer] WaveSpeed API timed out for scene {scene_number}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail={
|
||||
"error": "WaveSpeed request timed out",
|
||||
"scene_number": scene_number,
|
||||
"message": "The video generation request timed out.",
|
||||
"user_action": "Please retry. If it persists, try fewer scenes, lower resolution, or shorter durations.",
|
||||
},
|
||||
) from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"[YouTubeRenderer] WaveSpeed API request failed for scene {scene_number}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "WaveSpeed request failed",
|
||||
"scene_number": scene_number,
|
||||
"message": str(e),
|
||||
"user_action": "Please retry. If it persists, check network connectivity or try again later.",
|
||||
},
|
||||
) from e
|
||||
|
||||
# Save scene video
|
||||
video_service = StoryVideoGenerationService(output_dir=str(self.output_dir))
|
||||
save_result = video_service.save_scene_video(
|
||||
video_bytes=video_result["video_bytes"],
|
||||
scene_number=scene_number,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Update video URL to use YouTube API endpoint
|
||||
filename = save_result["video_filename"]
|
||||
save_result["video_url"] = f"/api/youtube/videos/{filename}"
|
||||
|
||||
# Track usage
|
||||
usage_info = track_video_usage(
|
||||
user_id=user_id,
|
||||
provider=video_result["provider"],
|
||||
model_name=video_result["model_name"],
|
||||
prompt=visual_prompt,
|
||||
video_bytes=video_result["video_bytes"],
|
||||
cost_override=video_result["cost"],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[YouTubeRenderer] ✅ Scene {scene_number} rendered: "
|
||||
f"cost=${video_result['cost']:.2f}, size={len(video_result['video_bytes'])} bytes"
|
||||
)
|
||||
|
||||
return {
|
||||
"scene_number": scene_number,
|
||||
"video_filename": save_result["video_filename"],
|
||||
"video_url": save_result["video_url"],
|
||||
"video_path": save_result["video_path"],
|
||||
"duration": video_result["duration"],
|
||||
"cost": video_result["cost"],
|
||||
"resolution": resolution,
|
||||
"width": video_result["width"],
|
||||
"height": video_result["height"],
|
||||
"file_size": save_result["file_size"],
|
||||
"prediction_id": video_result.get("prediction_id"),
|
||||
"usage_info": usage_info,
|
||||
}
|
||||
|
||||
except HTTPException as e:
|
||||
# Re-raise with better error message for UI
|
||||
error_detail = e.detail
|
||||
if isinstance(error_detail, dict):
|
||||
error_msg = error_detail.get("error", str(error_detail))
|
||||
else:
|
||||
error_msg = str(error_detail)
|
||||
|
||||
logger.error(
|
||||
f"[YouTubeRenderer] Scene {scene_number} failed: {error_msg}",
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=e.status_code,
|
||||
detail={
|
||||
"error": f"Failed to render scene {scene_number}",
|
||||
"scene_number": scene_number,
|
||||
"message": error_msg,
|
||||
"user_action": "Please try again. If the issue persists, check your scene content and try a different resolution.",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[YouTubeRenderer] Error rendering scene {scene_number}: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"error": f"Failed to render scene {scene_number}",
|
||||
"scene_number": scene_number,
|
||||
"message": str(e),
|
||||
"user_action": "Please try again. If the issue persists, check your scene content and try a different resolution.",
|
||||
}
|
||||
)
|
||||
|
||||
def render_full_video(
|
||||
self,
|
||||
scenes: List[Dict[str, Any]],
|
||||
video_plan: Dict[str, Any],
|
||||
user_id: str,
|
||||
resolution: str = "720p",
|
||||
combine_scenes: bool = True,
|
||||
voice_id: str = "Wise_Woman",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Render a complete video from multiple scenes.
|
||||
|
||||
Args:
|
||||
scenes: List of scene data
|
||||
video_plan: Original video plan
|
||||
user_id: Clerk user ID
|
||||
resolution: Video resolution
|
||||
combine_scenes: Whether to combine scenes into single video
|
||||
voice_id: Voice ID for narration
|
||||
|
||||
Returns:
|
||||
Dictionary with video metadata and scene results
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"[YouTubeRenderer] Rendering full video: {len(scenes)} scenes, "
|
||||
f"resolution={resolution}, user={user_id}"
|
||||
)
|
||||
|
||||
# Filter enabled scenes
|
||||
enabled_scenes = [s for s in scenes if s.get("enabled", True)]
|
||||
if not enabled_scenes:
|
||||
raise HTTPException(status_code=400, detail="No enabled scenes to render")
|
||||
|
||||
scene_results = []
|
||||
total_cost = 0.0
|
||||
|
||||
# Render each scene
|
||||
for idx, scene in enumerate(enabled_scenes):
|
||||
logger.info(
|
||||
f"[YouTubeRenderer] Rendering scene {idx + 1}/{len(enabled_scenes)}: "
|
||||
f"Scene {scene.get('scene_number', idx + 1)}"
|
||||
)
|
||||
|
||||
scene_result = self.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"]
|
||||
|
||||
# Combine scenes if requested
|
||||
final_video_path = None
|
||||
final_video_url = None
|
||||
if combine_scenes and len(scene_results) > 1:
|
||||
logger.info("[YouTubeRenderer] Combining scenes into final video...")
|
||||
|
||||
# Prepare data for video concatenation
|
||||
scene_video_paths = [r["video_path"] for r in scene_results]
|
||||
scene_audio_paths = [r.get("audio_path") for r in scene_results if r.get("audio_path")]
|
||||
|
||||
# Use StoryVideoGenerationService to combine
|
||||
video_service = StoryVideoGenerationService(output_dir=str(self.output_dir))
|
||||
|
||||
# Create scene dicts for concatenation
|
||||
scene_dicts = [
|
||||
{
|
||||
"scene_number": r["scene_number"],
|
||||
"title": f"Scene {r['scene_number']}",
|
||||
}
|
||||
for r in scene_results
|
||||
]
|
||||
|
||||
combined_result = video_service.generate_story_video(
|
||||
scenes=scene_dicts,
|
||||
image_paths=[None] * len(scene_results), # No static images
|
||||
audio_paths=scene_audio_paths if scene_audio_paths else [],
|
||||
video_paths=scene_video_paths, # Use rendered videos
|
||||
user_id=user_id,
|
||||
story_title=video_plan.get("video_summary", "YouTube Video")[:50],
|
||||
fps=24,
|
||||
)
|
||||
|
||||
final_video_path = combined_result["video_path"]
|
||||
final_video_url = combined_result["video_url"]
|
||||
|
||||
logger.info(
|
||||
f"[YouTubeRenderer] ✅ Full video rendered: {len(scene_results)} scenes, "
|
||||
f"total_cost=${total_cost:.2f}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"scene_results": scene_results,
|
||||
"total_cost": total_cost,
|
||||
"final_video_path": final_video_path,
|
||||
"final_video_url": final_video_url,
|
||||
"num_scenes": len(scene_results),
|
||||
"resolution": resolution,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[YouTubeRenderer] Error rendering full video: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to render video: {str(e)}"
|
||||
)
|
||||
|
||||
def estimate_render_cost(
|
||||
self,
|
||||
scenes: List[Dict[str, Any]],
|
||||
resolution: str = "720p",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Estimate the cost of rendering a video before actually rendering it.
|
||||
|
||||
Args:
|
||||
scenes: List of scene data with duration estimates
|
||||
resolution: Video resolution (480p, 720p, 1080p)
|
||||
|
||||
Returns:
|
||||
Dictionary with cost breakdown and total estimate
|
||||
"""
|
||||
# Pricing per second (same as in WaveSpeedClient)
|
||||
pricing = {
|
||||
"480p": 0.05,
|
||||
"720p": 0.10,
|
||||
"1080p": 0.15,
|
||||
}
|
||||
|
||||
price_per_second = pricing.get(resolution, 0.10)
|
||||
|
||||
# Filter enabled scenes
|
||||
enabled_scenes = [s for s in scenes if s.get("enabled", True)]
|
||||
|
||||
scene_costs = []
|
||||
total_cost = 0.0
|
||||
total_duration = 0.0
|
||||
|
||||
for scene in enabled_scenes:
|
||||
scene_number = scene.get("scene_number", 0)
|
||||
duration_estimate = scene.get("duration_estimate", 5)
|
||||
|
||||
# Clamp duration to valid WAN 2.5 values (5 or 10 seconds)
|
||||
duration = 5 if duration_estimate <= 7 else 10
|
||||
|
||||
scene_cost = price_per_second * duration
|
||||
scene_costs.append({
|
||||
"scene_number": scene_number,
|
||||
"duration_estimate": duration_estimate,
|
||||
"actual_duration": duration,
|
||||
"cost": round(scene_cost, 2),
|
||||
})
|
||||
|
||||
total_cost += scene_cost
|
||||
total_duration += duration
|
||||
|
||||
return {
|
||||
"resolution": resolution,
|
||||
"price_per_second": price_per_second,
|
||||
"num_scenes": len(enabled_scenes),
|
||||
"total_duration_seconds": total_duration,
|
||||
"scene_costs": scene_costs,
|
||||
"total_cost": round(total_cost, 2),
|
||||
"estimated_cost_range": {
|
||||
"min": round(total_cost * 0.9, 2), # 10% buffer
|
||||
"max": round(total_cost * 1.1, 2), # 10% buffer
|
||||
},
|
||||
}
|
||||
|
||||
551
backend/services/youtube/scene_builder.py
Normal file
551
backend/services/youtube/scene_builder.py
Normal file
@@ -0,0 +1,551 @@
|
||||
"""
|
||||
YouTube Scene Builder Service
|
||||
|
||||
Converts video plans into structured scenes with narration, visual prompts, and timing.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
from loguru import logger
|
||||
from fastapi import HTTPException
|
||||
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from services.story_writer.prompt_enhancer_service import PromptEnhancerService
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
logger = get_service_logger("youtube.scene_builder")
|
||||
|
||||
|
||||
class YouTubeSceneBuilderService:
|
||||
"""Service for building structured video scenes from plans."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the scene builder service."""
|
||||
self.prompt_enhancer = PromptEnhancerService()
|
||||
logger.info("[YouTubeSceneBuilder] Service initialized")
|
||||
|
||||
def build_scenes_from_plan(
|
||||
self,
|
||||
video_plan: Dict[str, Any],
|
||||
user_id: str,
|
||||
custom_script: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Build structured scenes from a video plan.
|
||||
|
||||
Args:
|
||||
video_plan: Video plan from planner service
|
||||
user_id: Clerk user ID for subscription checking
|
||||
custom_script: Optional custom script to use instead of generating
|
||||
|
||||
Returns:
|
||||
List of scene dictionaries with narration, visual prompts, timing, etc.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"[YouTubeSceneBuilder] Building scenes from plan: "
|
||||
f"duration={video_plan.get('duration_type')}, "
|
||||
f"sections={len(video_plan.get('content_outline', []))}"
|
||||
)
|
||||
|
||||
duration_metadata = video_plan.get("duration_metadata", {})
|
||||
max_scenes = duration_metadata.get("max_scenes", 10)
|
||||
|
||||
# If custom script provided, parse it into scenes
|
||||
if custom_script:
|
||||
scenes = self._parse_custom_script(
|
||||
custom_script, video_plan, duration_metadata, user_id
|
||||
)
|
||||
# For shorts, check if scenes were already generated in plan (optimization)
|
||||
elif video_plan.get("_scenes_included") and video_plan.get("duration_type") == "shorts":
|
||||
prebuilt = video_plan.get("scenes") or []
|
||||
if prebuilt:
|
||||
logger.info(
|
||||
f"[YouTubeSceneBuilder] Using scenes from optimized plan+scenes call "
|
||||
f"({len(prebuilt)} scenes)"
|
||||
)
|
||||
scenes = self._normalize_scenes_from_plan(video_plan, duration_metadata)
|
||||
else:
|
||||
logger.warning(
|
||||
"[YouTubeSceneBuilder] Plan marked _scenes_included but no scenes present; "
|
||||
"regenerating scenes normally."
|
||||
)
|
||||
scenes = self._generate_scenes_from_plan(
|
||||
video_plan, duration_metadata, user_id
|
||||
)
|
||||
else:
|
||||
# Generate scenes from plan
|
||||
scenes = self._generate_scenes_from_plan(
|
||||
video_plan, duration_metadata, user_id
|
||||
)
|
||||
|
||||
# Limit to max scenes
|
||||
if len(scenes) > max_scenes:
|
||||
logger.warning(
|
||||
f"[YouTubeSceneBuilder] Truncating {len(scenes)} scenes to {max_scenes}"
|
||||
)
|
||||
scenes = scenes[:max_scenes]
|
||||
|
||||
# Enhance visual prompts efficiently based on duration type
|
||||
duration_type = video_plan.get("duration_type", "medium")
|
||||
scenes = self._enhance_visual_prompts_batch(
|
||||
scenes, video_plan, user_id, duration_type
|
||||
)
|
||||
|
||||
logger.info(f"[YouTubeSceneBuilder] ✅ Built {len(scenes)} scenes")
|
||||
return scenes
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[YouTubeSceneBuilder] Error building scenes: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to build scenes: {str(e)}"
|
||||
)
|
||||
|
||||
def _generate_scenes_from_plan(
|
||||
self,
|
||||
video_plan: Dict[str, Any],
|
||||
duration_metadata: Dict[str, Any],
|
||||
user_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Generate scenes from video plan using AI."""
|
||||
|
||||
content_outline = video_plan.get("content_outline", [])
|
||||
hook_strategy = video_plan.get("hook_strategy", "")
|
||||
call_to_action = video_plan.get("call_to_action", "")
|
||||
visual_style = video_plan.get("visual_style", "cinematic")
|
||||
tone = video_plan.get("tone", "professional")
|
||||
|
||||
scene_duration_range = duration_metadata.get("scene_duration_range", (5, 15))
|
||||
|
||||
scene_generation_prompt = f"""You are an expert video scriptwriter. Create detailed scenes for a YouTube video based on this plan.
|
||||
|
||||
**Video Plan:**
|
||||
- Summary: {video_plan.get('video_summary', '')}
|
||||
- Goal: {video_plan.get('video_goal', '')}
|
||||
- Key Message: {video_plan.get('key_message', '')}
|
||||
- Visual Style: {visual_style}
|
||||
- Tone: {tone}
|
||||
|
||||
**Hook Strategy:**
|
||||
{hook_strategy}
|
||||
|
||||
**Content Outline:**
|
||||
{chr(10).join([f"- {section.get('section', '')}: {section.get('description', '')} ({section.get('duration_estimate', 0)}s)" for section in content_outline])}
|
||||
|
||||
**Call-to-Action:**
|
||||
{call_to_action}
|
||||
|
||||
**Duration Constraints:**
|
||||
- Scene duration: {scene_duration_range[0]}-{scene_duration_range[1]} seconds each
|
||||
- Total target: {duration_metadata.get('target_seconds', 150)} seconds
|
||||
|
||||
**Your Task:**
|
||||
Create detailed scenes that include:
|
||||
1. Scene number and title
|
||||
2. Narration text (what will be spoken)
|
||||
3. Visual description (what viewers will see)
|
||||
4. Duration estimate
|
||||
5. Emphasis tags (hook, main_content, transition, cta)
|
||||
|
||||
**Format as JSON array:**
|
||||
[
|
||||
{{
|
||||
"scene_number": 1,
|
||||
"title": "Hook - Attention Grabber",
|
||||
"narration": "The spoken text for this scene...",
|
||||
"visual_description": "Detailed description of what viewers see...",
|
||||
"duration_estimate": 5,
|
||||
"emphasis": "hook",
|
||||
"visual_cues": ["close-up", "dynamic", "bright"]
|
||||
}},
|
||||
...
|
||||
]
|
||||
|
||||
Make sure:
|
||||
- First scene is a strong hook ({duration_metadata.get('hook_seconds', 10)}s)
|
||||
- Last scene includes the CTA ({duration_metadata.get('cta_seconds', 10)}s)
|
||||
- Each scene has clear narration and visual description
|
||||
- Total duration fits within {duration_metadata.get('target_seconds', 150)} seconds
|
||||
- Scenes flow naturally from one to the next
|
||||
"""
|
||||
|
||||
system_prompt = (
|
||||
"You are an expert video scriptwriter specializing in YouTube content. "
|
||||
"Your scenes are engaging, well-paced, and optimized for viewer retention."
|
||||
)
|
||||
|
||||
response = llm_text_gen(
|
||||
prompt=scene_generation_prompt,
|
||||
system_prompt=system_prompt,
|
||||
user_id=user_id,
|
||||
json_struct={
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scene_number": {"type": "number"},
|
||||
"title": {"type": "string"},
|
||||
"narration": {"type": "string"},
|
||||
"visual_description": {"type": "string"},
|
||||
"duration_estimate": {"type": "number"},
|
||||
"emphasis": {"type": "string"},
|
||||
"visual_cues": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"scene_number", "title", "narration", "visual_description",
|
||||
"duration_estimate", "emphasis"
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Parse response
|
||||
if isinstance(response, list):
|
||||
scenes = response
|
||||
elif isinstance(response, dict) and "scenes" in response:
|
||||
scenes = response["scenes"]
|
||||
else:
|
||||
import json
|
||||
scenes = json.loads(response) if isinstance(response, str) else response
|
||||
|
||||
# Normalize scene data
|
||||
normalized_scenes = []
|
||||
for idx, scene in enumerate(scenes, 1):
|
||||
normalized_scenes.append({
|
||||
"scene_number": scene.get("scene_number", idx),
|
||||
"title": scene.get("title", f"Scene {idx}"),
|
||||
"narration": scene.get("narration", ""),
|
||||
"visual_description": scene.get("visual_description", ""),
|
||||
"duration_estimate": scene.get("duration_estimate", scene_duration_range[0]),
|
||||
"emphasis": scene.get("emphasis", "main_content"),
|
||||
"visual_cues": scene.get("visual_cues", []),
|
||||
"visual_prompt": scene.get("visual_description", ""), # Initial prompt
|
||||
})
|
||||
|
||||
return normalized_scenes
|
||||
|
||||
def _normalize_scenes_from_plan(
|
||||
self,
|
||||
video_plan: Dict[str, Any],
|
||||
duration_metadata: Dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Normalize scenes that were generated as part of the plan (optimization for shorts)."""
|
||||
scenes = video_plan.get("scenes", [])
|
||||
scene_duration_range = duration_metadata.get("scene_duration_range", (2, 8))
|
||||
|
||||
normalized_scenes = []
|
||||
for idx, scene in enumerate(scenes, 1):
|
||||
normalized_scenes.append({
|
||||
"scene_number": scene.get("scene_number", idx),
|
||||
"title": scene.get("title", f"Scene {idx}"),
|
||||
"narration": scene.get("narration", ""),
|
||||
"visual_description": scene.get("visual_description", ""),
|
||||
"duration_estimate": scene.get("duration_estimate", scene_duration_range[0]),
|
||||
"emphasis": scene.get("emphasis", "main_content"),
|
||||
"visual_cues": scene.get("visual_cues", []),
|
||||
"visual_prompt": scene.get("visual_description", ""), # Initial prompt
|
||||
})
|
||||
|
||||
logger.info(
|
||||
f"[YouTubeSceneBuilder] ✅ Normalized {len(normalized_scenes)} scenes "
|
||||
f"from optimized plan (saved 1 AI call)"
|
||||
)
|
||||
return normalized_scenes
|
||||
|
||||
def _parse_custom_script(
|
||||
self,
|
||||
custom_script: str,
|
||||
video_plan: Dict[str, Any],
|
||||
duration_metadata: Dict[str, Any],
|
||||
user_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Parse a custom script into structured scenes."""
|
||||
# Simple parsing: split by double newlines or scene markers
|
||||
import re
|
||||
|
||||
# Try to detect scene markers
|
||||
scene_pattern = r'(?:Scene\s+\d+|#\s*\d+\.|^\d+\.)\s*(.+?)(?=(?:Scene\s+\d+|#\s*\d+\.|^\d+\.|$))'
|
||||
matches = re.finditer(scene_pattern, custom_script, re.MULTILINE | re.DOTALL)
|
||||
|
||||
scenes = []
|
||||
for idx, match in enumerate(matches, 1):
|
||||
scene_text = match.group(1).strip()
|
||||
# Extract narration (first paragraph or before visual markers)
|
||||
narration_match = re.search(r'^(.*?)(?:\n\n|Visual:|Image:)', scene_text, re.DOTALL)
|
||||
narration = narration_match.group(1).strip() if narration_match else scene_text.split('\n')[0]
|
||||
|
||||
# Extract visual description
|
||||
visual_match = re.search(r'(?:Visual:|Image:)\s*(.+?)(?:\n\n|$)', scene_text, re.DOTALL)
|
||||
visual_description = visual_match.group(1).strip() if visual_match else narration
|
||||
|
||||
scenes.append({
|
||||
"scene_number": idx,
|
||||
"title": f"Scene {idx}",
|
||||
"narration": narration,
|
||||
"visual_description": visual_description,
|
||||
"duration_estimate": duration_metadata.get("scene_duration_range", [5, 15])[0],
|
||||
"emphasis": "hook" if idx == 1 else ("cta" if idx == len(list(matches)) else "main_content"),
|
||||
"visual_cues": [],
|
||||
"visual_prompt": visual_description,
|
||||
})
|
||||
|
||||
# Fallback: split by paragraphs if no scene markers
|
||||
if not scenes:
|
||||
paragraphs = [p.strip() for p in custom_script.split('\n\n') if p.strip()]
|
||||
for idx, para in enumerate(paragraphs[:duration_metadata.get("max_scenes", 10)], 1):
|
||||
scenes.append({
|
||||
"scene_number": idx,
|
||||
"title": f"Scene {idx}",
|
||||
"narration": para,
|
||||
"visual_description": para,
|
||||
"duration_estimate": duration_metadata.get("scene_duration_range", [5, 15])[0],
|
||||
"emphasis": "hook" if idx == 1 else ("cta" if idx == len(paragraphs) else "main_content"),
|
||||
"visual_cues": [],
|
||||
"visual_prompt": para,
|
||||
})
|
||||
|
||||
return scenes
|
||||
|
||||
def _enhance_visual_prompts_batch(
|
||||
self,
|
||||
scenes: List[Dict[str, Any]],
|
||||
video_plan: Dict[str, Any],
|
||||
user_id: str,
|
||||
duration_type: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Efficiently enhance visual prompts based on video duration type.
|
||||
|
||||
Strategy:
|
||||
- Shorts: Skip enhancement (use original descriptions) - 0 AI calls
|
||||
- Medium: Batch enhance all scenes in 1 call - 1 AI call
|
||||
- Long: Batch enhance in 2 calls (split scenes) - 2 AI calls max
|
||||
"""
|
||||
# For shorts, skip enhancement to save API calls
|
||||
if duration_type == "shorts":
|
||||
logger.info(
|
||||
f"[YouTubeSceneBuilder] Skipping prompt enhancement for shorts "
|
||||
f"({len(scenes)} scenes) to save API calls"
|
||||
)
|
||||
for scene in scenes:
|
||||
scene["enhanced_visual_prompt"] = scene.get(
|
||||
"visual_prompt", scene.get("visual_description", "")
|
||||
)
|
||||
return scenes
|
||||
|
||||
# Build story context for prompt enhancer
|
||||
story_context = {
|
||||
"story_setting": video_plan.get("visual_style", "cinematic"),
|
||||
"story_tone": video_plan.get("tone", "professional"),
|
||||
"writing_style": video_plan.get("visual_style", "cinematic"),
|
||||
}
|
||||
|
||||
# Convert scenes to format expected by enhancer
|
||||
scene_data_list = [
|
||||
{
|
||||
"scene_number": scene.get("scene_number", idx + 1),
|
||||
"title": scene.get("title", ""),
|
||||
"description": scene.get("visual_description", ""),
|
||||
"image_prompt": scene.get("visual_prompt", ""),
|
||||
}
|
||||
for idx, scene in enumerate(scenes)
|
||||
]
|
||||
|
||||
# For medium videos, enhance all scenes in one batch call
|
||||
if duration_type == "medium":
|
||||
logger.info(
|
||||
f"[YouTubeSceneBuilder] Batch enhancing {len(scenes)} scenes "
|
||||
f"for medium video in 1 AI call"
|
||||
)
|
||||
try:
|
||||
# Use a single batch enhancement call
|
||||
enhanced_prompts = self._batch_enhance_prompts(
|
||||
scene_data_list, story_context, user_id
|
||||
)
|
||||
for idx, scene in enumerate(scenes):
|
||||
scene["enhanced_visual_prompt"] = enhanced_prompts.get(
|
||||
idx, scene.get("visual_prompt", scene.get("visual_description", ""))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[YouTubeSceneBuilder] Batch enhancement failed: {e}, "
|
||||
f"using original prompts"
|
||||
)
|
||||
for scene in scenes:
|
||||
scene["enhanced_visual_prompt"] = scene.get(
|
||||
"visual_prompt", scene.get("visual_description", "")
|
||||
)
|
||||
return scenes
|
||||
|
||||
# For long videos, split into 2 batches to avoid token limits
|
||||
if duration_type == "long":
|
||||
logger.info(
|
||||
f"[YouTubeSceneBuilder] Batch enhancing {len(scenes)} scenes "
|
||||
f"for long video in 2 AI calls"
|
||||
)
|
||||
mid_point = len(scenes) // 2
|
||||
batches = [
|
||||
scene_data_list[:mid_point],
|
||||
scene_data_list[mid_point:],
|
||||
]
|
||||
|
||||
all_enhanced = {}
|
||||
for batch_idx, batch in enumerate(batches):
|
||||
try:
|
||||
enhanced = self._batch_enhance_prompts(
|
||||
batch, story_context, user_id
|
||||
)
|
||||
start_idx = 0 if batch_idx == 0 else mid_point
|
||||
for local_idx, enhanced_prompt in enhanced.items():
|
||||
all_enhanced[start_idx + local_idx] = enhanced_prompt
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[YouTubeSceneBuilder] Batch {batch_idx + 1} enhancement "
|
||||
f"failed: {e}, using original prompts"
|
||||
)
|
||||
start_idx = 0 if batch_idx == 0 else mid_point
|
||||
for local_idx, scene_data in enumerate(batch):
|
||||
all_enhanced[start_idx + local_idx] = scene_data.get(
|
||||
"image_prompt", scene_data.get("description", "")
|
||||
)
|
||||
|
||||
for idx, scene in enumerate(scenes):
|
||||
scene["enhanced_visual_prompt"] = all_enhanced.get(
|
||||
idx, scene.get("visual_prompt", scene.get("visual_description", ""))
|
||||
)
|
||||
return scenes
|
||||
|
||||
# Fallback: use original prompts
|
||||
logger.warning(
|
||||
f"[YouTubeSceneBuilder] Unknown duration type '{duration_type}', "
|
||||
f"using original prompts"
|
||||
)
|
||||
for scene in scenes:
|
||||
scene["enhanced_visual_prompt"] = scene.get(
|
||||
"visual_prompt", scene.get("visual_description", "")
|
||||
)
|
||||
return scenes
|
||||
|
||||
def _batch_enhance_prompts(
|
||||
self,
|
||||
scene_data_list: List[Dict[str, Any]],
|
||||
story_context: Dict[str, Any],
|
||||
user_id: str,
|
||||
) -> Dict[int, str]:
|
||||
"""
|
||||
Enhance multiple scene prompts in a single AI call.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping scene index to enhanced prompt
|
||||
"""
|
||||
try:
|
||||
# Build batch enhancement prompt
|
||||
scenes_text = "\n\n".join([
|
||||
f"Scene {scene.get('scene_number', idx + 1)}: {scene.get('title', '')}\n"
|
||||
f"Description: {scene.get('description', '')}\n"
|
||||
f"Current Prompt: {scene.get('image_prompt', '')}"
|
||||
for idx, scene in enumerate(scene_data_list)
|
||||
])
|
||||
|
||||
batch_prompt = f"""You are optimizing visual prompts for AI video generation. Enhance the following scenes to be more detailed and video-optimized.
|
||||
|
||||
**Video Style Context:**
|
||||
- Setting: {story_context.get('story_setting', 'cinematic')}
|
||||
- Tone: {story_context.get('story_tone', 'professional')}
|
||||
- Style: {story_context.get('writing_style', 'cinematic')}
|
||||
|
||||
**Scenes to Enhance:**
|
||||
{scenes_text}
|
||||
|
||||
**Your Task:**
|
||||
For each scene, create an enhanced visual prompt (200-300 words) that:
|
||||
1. Is detailed and specific for video generation
|
||||
2. Includes camera movements, lighting, composition
|
||||
3. Maintains consistency with the video style
|
||||
4. Is optimized for WAN 2.5 text-to-video model
|
||||
|
||||
**Format as JSON array with enhanced prompts:**
|
||||
[
|
||||
{{"scene_index": 0, "enhanced_prompt": "detailed enhanced prompt for scene 1..."}},
|
||||
{{"scene_index": 1, "enhanced_prompt": "detailed enhanced prompt for scene 2..."}},
|
||||
...
|
||||
]
|
||||
|
||||
Make sure the array length matches the number of scenes provided ({len(scene_data_list)}).
|
||||
"""
|
||||
|
||||
system_prompt = (
|
||||
"You are an expert at creating detailed visual prompts for AI video generation. "
|
||||
"Your prompts are specific, cinematic, and optimized for video models."
|
||||
)
|
||||
|
||||
response = llm_text_gen(
|
||||
prompt=batch_prompt,
|
||||
system_prompt=system_prompt,
|
||||
user_id=user_id,
|
||||
json_struct={
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scene_index": {"type": "number"},
|
||||
"enhanced_prompt": {"type": "string"}
|
||||
},
|
||||
"required": ["scene_index", "enhanced_prompt"]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Parse response
|
||||
if isinstance(response, list):
|
||||
enhanced_list = response
|
||||
elif isinstance(response, str):
|
||||
import json
|
||||
enhanced_list = json.loads(response)
|
||||
else:
|
||||
enhanced_list = response
|
||||
|
||||
# Build result dictionary
|
||||
result = {}
|
||||
for item in enhanced_list:
|
||||
idx = item.get("scene_index", 0)
|
||||
prompt = item.get("enhanced_prompt", "")
|
||||
if prompt:
|
||||
result[idx] = prompt
|
||||
else:
|
||||
# Fallback to original
|
||||
original_scene = scene_data_list[idx] if idx < len(scene_data_list) else {}
|
||||
result[idx] = original_scene.get(
|
||||
"image_prompt", original_scene.get("description", "")
|
||||
)
|
||||
|
||||
# Fill in any missing scenes with original prompts
|
||||
for idx in range(len(scene_data_list)):
|
||||
if idx not in result:
|
||||
original_scene = scene_data_list[idx]
|
||||
result[idx] = original_scene.get(
|
||||
"image_prompt", original_scene.get("description", "")
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[YouTubeSceneBuilder] ✅ Batch enhanced {len(result)} prompts "
|
||||
f"in 1 AI call"
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[YouTubeSceneBuilder] Batch enhancement failed: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
# Return original prompts as fallback
|
||||
return {
|
||||
idx: scene.get("image_prompt", scene.get("description", ""))
|
||||
for idx, scene in enumerate(scene_data_list)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user