""" Podcast Maker API Router API endpoints for podcast project persistence and management. """ from fastapi import APIRouter, Depends, HTTPException, Query, Body, BackgroundTasks, Request from sqlalchemy.orm import Session from typing import List, Optional, Dict, Any from pydantic import BaseModel, Field from datetime import datetime from pathlib import Path from urllib.parse import quote from services.database import get_db from middleware.auth_middleware import get_current_user from services.podcast_service import PodcastService from models.podcast_models import PodcastProject from services.wavespeed.infinitetalk import animate_scene_with_voiceover from services.story_writer.video_generation_service import StoryVideoGenerationService from services.llm_providers.main_video_generation import track_video_usage from services.subscription import PricingService from services.subscription.preflight_validator import validate_scene_animation_operation from api.story_writer.task_manager import task_manager from api.story_writer.utils.auth import require_authenticated_user from api.story_writer.utils.media_utils import load_story_image_bytes, load_story_audio_bytes from services.llm_providers.main_text_generation import llm_text_gen from services.story_writer.audio_generation_service import StoryAudioGenerationService from utils.asset_tracker import save_asset_to_library from models.story_models import StoryAudioResult from loguru import logger router = APIRouter(prefix="/api/podcast", tags=["Podcast Maker"]) AI_VIDEO_SUBDIR = Path("AI_Videos") audio_service = StoryAudioGenerationService() class PodcastProjectResponse(BaseModel): """Response model for podcast project.""" id: int project_id: str user_id: str idea: str duration: int speakers: int budget_cap: float analysis: Optional[Dict[str, Any]] = None queries: Optional[List[Dict[str, Any]]] = None selected_queries: Optional[List[str]] = None research: Optional[Dict[str, Any]] = None raw_research: Optional[Dict[str, Any]] = None estimate: Optional[Dict[str, Any]] = None script_data: Optional[Dict[str, Any]] = None render_jobs: Optional[List[Dict[str, Any]]] = None knobs: Optional[Dict[str, Any]] = None research_provider: Optional[str] = None show_script_editor: bool = False show_render_queue: bool = False current_step: Optional[str] = None status: str = "draft" is_favorite: bool = False created_at: datetime updated_at: datetime class Config: from_attributes = True class PodcastAnalyzeRequest(BaseModel): """Request model for podcast idea analysis.""" idea: str = Field(..., description="Podcast topic or idea") duration: int = Field(default=10, description="Target duration in minutes") speakers: int = Field(default=1, description="Number of speakers") class PodcastAnalyzeResponse(BaseModel): """Response model for podcast idea analysis.""" audience: str content_type: str top_keywords: list[str] suggested_outlines: list[Dict[str, Any]] title_suggestions: list[str] class PodcastScriptRequest(BaseModel): """Request model for podcast script generation.""" idea: str = Field(..., description="Podcast idea or topic") duration_minutes: int = Field(default=10, description="Target duration in minutes") speakers: int = Field(default=1, description="Number of speakers") research: Optional[Dict[str, Any]] = Field(None, description="Optional research payload to ground the script") class PodcastSceneLine(BaseModel): speaker: str text: str class PodcastScene(BaseModel): id: str title: str duration: int lines: list[PodcastSceneLine] approved: bool = False class PodcastScriptResponse(BaseModel): scenes: list[PodcastScene] class PodcastAudioRequest(BaseModel): """Generate TTS for a podcast scene.""" scene_id: str scene_title: str text: str voice_id: Optional[str] = "Wise_Woman" speed: Optional[float] = 1.0 volume: Optional[float] = 1.0 pitch: Optional[float] = 0.0 emotion: Optional[str] = "neutral" class PodcastAudioResponse(BaseModel): scene_id: str scene_title: str audio_filename: str audio_url: str provider: str model: str voice_id: str text_length: int file_size: int cost: float class PodcastProjectListResponse(BaseModel): """Response model for project list.""" projects: List[PodcastProjectResponse] total: int limit: int offset: int class CreateProjectRequest(BaseModel): """Request model for creating a project.""" project_id: str = Field(..., description="Unique project ID") idea: str = Field(..., description="Episode idea or URL") duration: int = Field(..., description="Duration in minutes") speakers: int = Field(default=1, description="Number of speakers") budget_cap: float = Field(default=50.0, description="Budget cap in USD") class UpdateProjectRequest(BaseModel): """Request model for updating project state.""" analysis: Optional[Dict[str, Any]] = None queries: Optional[List[Dict[str, Any]]] = None selected_queries: Optional[List[str]] = None research: Optional[Dict[str, Any]] = None raw_research: Optional[Dict[str, Any]] = None estimate: Optional[Dict[str, Any]] = None script_data: Optional[Dict[str, Any]] = None render_jobs: Optional[List[Dict[str, Any]]] = None knobs: Optional[Dict[str, Any]] = None research_provider: Optional[str] = None show_script_editor: Optional[bool] = None show_render_queue: Optional[bool] = None current_step: Optional[str] = None status: Optional[str] = None @router.post("/projects", response_model=PodcastProjectResponse, status_code=201) async def create_project( request: CreateProjectRequest, db: Session = Depends(get_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """Create a new podcast project.""" 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") service = PodcastService(db) # Check if project_id already exists for this user existing = service.get_project(user_id, request.project_id) if existing: raise HTTPException(status_code=400, detail="Project ID already exists") project = service.create_project( user_id=user_id, project_id=request.project_id, idea=request.idea, duration=request.duration, speakers=request.speakers, budget_cap=request.budget_cap, ) return PodcastProjectResponse.model_validate(project) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error creating project: {str(e)}") @router.post("/analyze", response_model=PodcastAnalyzeResponse) async def analyze_podcast_idea( request: PodcastAnalyzeRequest, current_user: Dict[str, Any] = Depends(get_current_user), ): """ Analyze a podcast idea and return podcast-oriented outlines, keywords, and titles. This uses the shared LLM provider but with a podcast-specific prompt (not story format). """ user_id = require_authenticated_user(current_user) prompt = f""" You are an expert podcast producer. Given a podcast idea, craft concise podcast-ready assets that sound like episode plans (not fiction stories). Podcast Idea: "{request.idea}" Duration: ~{request.duration} minutes Speakers: {request.speakers} (host + optional guest) Return JSON with: - audience: short target audience description - content_type: podcast style/format - top_keywords: 5 podcast-relevant keywords/phrases - suggested_outlines: 2 items, each with title (<=60 chars) and 4-6 short segments (bullet-friendly, factual) - title_suggestions: 3 concise episode titles (no cliffhanger storytelling) Requirements: - Keep language factual, actionable, and suited for spoken audio. - Avoid narrative fiction tone; focus on insights, hooks, objections, and takeaways. - Prefer 2024-2025 context when relevant. """ try: raw = llm_text_gen(prompt=prompt, user_id=user_id, json_struct=None) except Exception as exc: raise HTTPException(status_code=500, detail=f"Analysis failed: {exc}") # Normalize response (accept dict or JSON string) import json if isinstance(raw, str): try: data = json.loads(raw) except json.JSONDecodeError: raise HTTPException(status_code=500, detail="LLM returned non-JSON output") elif isinstance(raw, dict): data = raw else: raise HTTPException(status_code=500, detail="Unexpected LLM response format") audience = data.get("audience") or "Growth-focused professionals" content_type = data.get("content_type") or "Interview + insights" top_keywords = data.get("top_keywords") or [] suggested_outlines = data.get("suggested_outlines") or [] title_suggestions = data.get("title_suggestions") or [] return PodcastAnalyzeResponse( audience=audience, content_type=content_type, top_keywords=top_keywords, suggested_outlines=suggested_outlines, title_suggestions=title_suggestions, ) @router.post("/script", response_model=PodcastScriptResponse) async def generate_podcast_script( request: PodcastScriptRequest, current_user: Dict[str, Any] = Depends(get_current_user), ): """ Generate a podcast script outline (scenes + lines) using podcast-oriented prompting. """ user_id = require_authenticated_user(current_user) research_snippet = "" if request.research: try: key_insights = request.research.get("keyword_analysis", {}).get("key_insights") or [] sources = request.research.get("sources", []) or [] top_sources = [s.get("url") for s in sources[:3] if s.get("url")] research_snippet = f"Key insights: {key_insights}. Top sources: {top_sources}" except Exception: research_snippet = "" prompt = f""" You are an expert podcast script planner. Create concise, podcast-ready scenes (not narrative fiction). Podcast Idea: "{request.idea}" Duration: ~{request.duration_minutes} minutes Speakers: {request.speakers} (Host + optional Guest) Research (if any): {research_snippet} Return JSON with: - scenes: array of scenes. Each scene has: - id: string - title: short scene title (<= 60 chars) - duration: duration in seconds (aim for evenly split across total duration) - lines: array of {{"speaker": "...", "text": "..."}}, 3-6 lines per scene, succinct and spoken-friendly. Requirements: - Keep language conversational, factual, and action-oriented (no cliffhangers or fictional storytelling). - Include hooks, objections, counters, and takeaways where relevant. - Cite no URLs in the lines; keep them clean for narration. - Ensure total duration aligns with ~{request.duration_minutes} minutes across all scenes. """ try: raw = llm_text_gen(prompt=prompt, user_id=user_id, json_struct=None) except Exception as exc: raise HTTPException(status_code=500, detail=f"Script generation failed: {exc}") import json if isinstance(raw, str): try: data = json.loads(raw) except json.JSONDecodeError: raise HTTPException(status_code=500, detail="LLM returned non-JSON output") elif isinstance(raw, dict): data = raw else: raise HTTPException(status_code=500, detail="Unexpected LLM response format") scenes_data = data.get("scenes") or [] if not isinstance(scenes_data, list): raise HTTPException(status_code=500, detail="LLM response missing scenes array") # Normalize scenes scenes: list[PodcastScene] = [] for idx, scene in enumerate(scenes_data): title = scene.get("title") or f"Scene {idx + 1}" duration = int(scene.get("duration") or max(30, (request.duration_minutes * 60) // max(1, len(scenes_data)))) lines_raw = scene.get("lines") or [] lines: list[PodcastSceneLine] = [] for line in lines_raw: speaker = line.get("speaker") or ("Host" if len(lines) % request.speakers == 0 else "Guest") text = line.get("text") or "" if text: lines.append(PodcastSceneLine(speaker=speaker, text=text)) scenes.append( PodcastScene( id=scene.get("id") or f"scene-{idx + 1}", title=title, duration=duration, lines=lines, approved=False, ) ) return PodcastScriptResponse(scenes=scenes) @router.post("/audio", response_model=PodcastAudioResponse) async def generate_podcast_audio( request: PodcastAudioRequest, current_user: Dict[str, Any] = Depends(get_current_user), db: Session = Depends(get_db), ): """ Generate AI audio for a podcast scene using shared audio service. """ user_id = require_authenticated_user(current_user) if not request.text or not request.text.strip(): raise HTTPException(status_code=400, detail="Text is required") try: result: StoryAudioResult = audio_service.generate_ai_audio( scene_number=0, scene_title=request.scene_title, text=request.text.strip(), user_id=user_id, voice_id=request.voice_id or "Wise_Woman", speed=request.speed or 1.0, volume=request.volume or 1.0, pitch=request.pitch or 0.0, emotion=request.emotion or "neutral", ) except Exception as exc: raise HTTPException(status_code=500, detail=f"Audio generation failed: {exc}") # Save to asset library (podcast module) try: if result.get("audio_url"): save_asset_to_library( db=db, user_id=user_id, asset_type="audio", source_module="podcast_maker", filename=result.get("audio_filename", ""), file_url=result.get("audio_url", ""), file_path=result.get("audio_path"), file_size=result.get("file_size"), mime_type="audio/mpeg", title=f"{request.scene_title} - Podcast", description="Podcast scene narration", tags=["podcast", "audio", request.scene_id], provider=result.get("provider"), model=result.get("model"), cost=result.get("cost"), asset_metadata={ "scene_id": request.scene_id, "scene_title": request.scene_title, "status": "completed", }, ) except Exception as e: logger.warning(f"[Podcast] Failed to save audio asset: {e}") return PodcastAudioResponse( scene_id=request.scene_id, scene_title=request.scene_title, audio_filename=result.get("audio_filename", ""), audio_url=result.get("audio_url", ""), provider=result.get("provider", "wavespeed"), model=result.get("model", "minimax/speech-02-hd"), voice_id=result.get("voice_id", request.voice_id or "Wise_Woman"), text_length=result.get("text_length", len(request.text)), file_size=result.get("file_size", 0), cost=result.get("cost", 0.0), ) @router.get("/task/{task_id}/status") async def podcast_task_status(task_id: str, current_user: Dict[str, Any] = Depends(get_current_user)): """Expose task status under podcast namespace (reuses shared task manager).""" require_authenticated_user(current_user) return task_manager.get_task_status(task_id) @router.get("/projects/{project_id}", response_model=PodcastProjectResponse) async def get_project( project_id: str, db: Session = Depends(get_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """Get a podcast project by ID.""" 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") service = PodcastService(db) project = service.get_project(user_id, project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") return PodcastProjectResponse.model_validate(project) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error fetching project: {str(e)}") @router.put("/projects/{project_id}", response_model=PodcastProjectResponse) async def update_project( project_id: str, request: UpdateProjectRequest, db: Session = Depends(get_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """Update a podcast project state.""" 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") service = PodcastService(db) # Convert request to dict, excluding None values updates = request.model_dump(exclude_unset=True) project = service.update_project(user_id, project_id, **updates) if not project: raise HTTPException(status_code=404, detail="Project not found") return PodcastProjectResponse.model_validate(project) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error updating project: {str(e)}") @router.get("/projects", response_model=PodcastProjectListResponse) async def list_projects( status: Optional[str] = Query(None, description="Filter by status"), favorites_only: bool = Query(False, description="Only favorites"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), order_by: str = Query("updated_at", description="Order by: updated_at or created_at"), db: Session = Depends(get_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """List user's podcast projects.""" 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") if order_by not in ["updated_at", "created_at"]: raise HTTPException(status_code=400, detail="order_by must be 'updated_at' or 'created_at'") service = PodcastService(db) projects, total = service.list_projects( user_id=user_id, status=status, favorites_only=favorites_only, limit=limit, offset=offset, order_by=order_by, ) return PodcastProjectListResponse( projects=[PodcastProjectResponse.model_validate(p) for p in projects], total=total, limit=limit, offset=offset, ) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error listing projects: {str(e)}") @router.delete("/projects/{project_id}", status_code=204) async def delete_project( project_id: str, db: Session = Depends(get_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """Delete a podcast project.""" 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") service = PodcastService(db) deleted = service.delete_project(user_id, project_id) if not deleted: raise HTTPException(status_code=404, detail="Project not found") return None except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error deleting project: {str(e)}") @router.post("/projects/{project_id}/favorite", response_model=PodcastProjectResponse) async def toggle_favorite( project_id: str, db: Session = Depends(get_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """Toggle favorite status of a project.""" 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") service = PodcastService(db) project = service.toggle_favorite(user_id, project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") return PodcastProjectResponse.model_validate(project) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error toggling favorite: {str(e)}") class PodcastVideoGenerationRequest(BaseModel): """Request model for podcast video generation.""" project_id: str = Field(..., description="Podcast project ID") scene_id: str = Field(..., description="Scene ID") scene_title: str = Field(..., description="Scene title") audio_url: str = Field(..., description="URL to the generated audio file") avatar_image_url: Optional[str] = Field(None, description="URL to avatar image (optional)") resolution: str = Field("720p", description="Video resolution (480p or 720p)") prompt: Optional[str] = Field(None, description="Optional animation prompt override") class PodcastVideoGenerationResponse(BaseModel): """Response model for podcast video generation.""" task_id: str status: str message: str def _load_audio_bytes_from_url(audio_url: str) -> bytes: """Load audio bytes from URL.""" import requests # Try to resolve as story audio first audio_bytes = load_story_audio_bytes(audio_url) if audio_bytes: return audio_bytes # Fallback: try direct HTTP request try: response = requests.get(audio_url, timeout=30) if response.status_code == 200: return response.content except Exception as e: logger.warning(f"Failed to load audio from URL {audio_url}: {e}") raise HTTPException(status_code=404, detail=f"Audio file not found: {audio_url}") def _load_image_bytes_from_url(image_url: str) -> bytes: """Load image bytes from URL.""" import requests # Try to resolve as story image first image_bytes = load_story_image_bytes(image_url) if image_bytes: return image_bytes # Fallback: try direct HTTP request try: response = requests.get(image_url, timeout=30) if response.status_code == 200: return response.content except Exception as e: logger.warning(f"Failed to load image from URL {image_url}: {e}") raise HTTPException(status_code=404, detail=f"Image file not found: {image_url}") def _execute_podcast_video_task( task_id: str, request: PodcastVideoGenerationRequest, user_id: str, image_bytes: bytes, audio_bytes: bytes, auth_token: Optional[str] = None, ): """Background task to generate InfiniteTalk video for podcast scene.""" try: task_manager.update_task_status( task_id, "processing", progress=5.0, message="Submitting to WaveSpeed InfiniteTalk..." ) # Extract scene number from scene_id scene_number_match = re.search(r'\d+', request.scene_id) scene_number = int(scene_number_match.group()) if scene_number_match else 0 # Prepare scene data for animation scene_data = { "scene_number": scene_number, "title": request.scene_title, "scene_id": request.scene_id, } story_context = { "project_id": request.project_id, "type": "podcast", } animation_result = animate_scene_with_voiceover( image_bytes=image_bytes, audio_bytes=audio_bytes, scene_data=scene_data, story_context=story_context, user_id=user_id, resolution=request.resolution or "720p", prompt_override=request.prompt, image_mime="image/png", audio_mime="audio/mpeg", ) task_manager.update_task_status( task_id, "processing", progress=80.0, message="Saving video file..." ) base_dir = Path(__file__).parent.parent.parent.parent ai_video_dir = base_dir / "story_videos" / AI_VIDEO_SUBDIR ai_video_dir.mkdir(parents=True, exist_ok=True) video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir)) save_result = video_service.save_scene_video( video_bytes=animation_result["video_bytes"], scene_number=scene_number, user_id=user_id, ) video_filename = save_result["video_filename"] video_url = f"/api/story/videos/ai/{video_filename}" if auth_token: video_url = f"{video_url}?token={quote(auth_token)}" usage_info = track_video_usage( user_id=user_id, provider=animation_result["provider"], model_name=animation_result["model_name"], prompt=animation_result["prompt"], video_bytes=animation_result["video_bytes"], cost_override=animation_result["cost"], ) task_manager.update_task_status( task_id, "completed", progress=100.0, message="Video generation complete!", result={ "video_url": video_url, "video_filename": video_filename, "cost": animation_result["cost"], "duration": animation_result["duration"], "provider": animation_result["provider"], "model": animation_result["model_name"], }, ) logger.info( f"[Podcast] Video generation completed for project {request.project_id}, scene {request.scene_id}" ) except Exception as exc: logger.error(f"[Podcast] Video generation failed: {exc}", exc_info=True) task_manager.update_task_status( task_id, "failed", error=str(exc), message=f"Video generation failed: {exc}" ) @router.post("/render/video", response_model=PodcastVideoGenerationResponse) async def generate_podcast_video( request_obj: Request, request: PodcastVideoGenerationRequest, background_tasks: BackgroundTasks, current_user: Dict[str, Any] = Depends(get_current_user), ): """ Generate video for a podcast scene using WaveSpeed InfiniteTalk (avatar image + audio). Returns task_id for polling since InfiniteTalk can take up to 10 minutes. """ user_id = require_authenticated_user(current_user) logger.info( f"[Podcast] Starting video generation for project {request.project_id}, scene {request.scene_id}" ) # Load audio bytes audio_bytes = _load_audio_bytes_from_url(request.audio_url) # Load image bytes (use avatar if provided, otherwise generate default) if request.avatar_image_url: image_bytes = _load_image_bytes_from_url(request.avatar_image_url) else: # Generate a default avatar image or use a placeholder # For now, raise an error if no avatar is provided raise HTTPException( status_code=400, detail="Avatar image is required for video generation. Please upload an avatar image.", ) # Validate subscription limits db = next(get_db()) try: pricing_service = PricingService(db) validate_scene_animation_operation(pricing_service=pricing_service, user_id=user_id) finally: db.close() # Extract token for authenticated URL building auth_token = None auth_header = request_obj.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): auth_token = auth_header.replace("Bearer ", "").strip() # Create async task task_id = task_manager.create_task("podcast_video_generation") background_tasks.add_task( _execute_podcast_video_task, task_id=task_id, request=request, user_id=user_id, image_bytes=image_bytes, audio_bytes=audio_bytes, auth_token=auth_token, ) return PodcastVideoGenerationResponse( task_id=task_id, status="pending", message="Video generation started. This may take up to 10 minutes.", ) class PodcastVideoGenerationRequest(BaseModel): """Request model for podcast video generation.""" project_id: str = Field(..., description="Podcast project ID") scene_id: str = Field(..., description="Scene ID") scene_title: str = Field(..., description="Scene title") audio_url: str = Field(..., description="URL to the generated audio file") avatar_image_url: Optional[str] = Field(None, description="URL to avatar image (optional)") resolution: str = Field("720p", description="Video resolution (480p or 720p)") prompt: Optional[str] = Field(None, description="Optional animation prompt override") class PodcastVideoGenerationResponse(BaseModel): """Response model for podcast video generation.""" task_id: str status: str message: str def _load_audio_bytes_from_url(audio_url: str) -> bytes: """Load audio bytes from URL.""" import requests # Try to resolve as story audio first audio_bytes = load_story_audio_bytes(audio_url) if audio_bytes: return audio_bytes # Fallback: try direct HTTP request try: response = requests.get(audio_url, timeout=30) if response.status_code == 200: return response.content except Exception as e: logger.warning(f"Failed to load audio from URL {audio_url}: {e}") raise HTTPException(status_code=404, detail=f"Audio file not found: {audio_url}") def _load_image_bytes_from_url(image_url: str) -> bytes: """Load image bytes from URL.""" import requests # Try to resolve as story image first image_bytes = load_story_image_bytes(image_url) if image_bytes: return image_bytes # Fallback: try direct HTTP request try: response = requests.get(image_url, timeout=30) if response.status_code == 200: return response.content except Exception as e: logger.warning(f"Failed to load image from URL {image_url}: {e}") raise HTTPException(status_code=404, detail=f"Image file not found: {image_url}") def _execute_podcast_video_task( task_id: str, request: PodcastVideoGenerationRequest, user_id: str, image_bytes: bytes, audio_bytes: bytes, auth_token: Optional[str] = None, ): """Background task to generate InfiniteTalk video for podcast scene.""" try: task_manager.update_task_status( task_id, "processing", progress=5.0, message="Submitting to WaveSpeed InfiniteTalk..." ) # Extract scene number from scene_id scene_number_match = re.search(r'\d+', request.scene_id) scene_number = int(scene_number_match.group()) if scene_number_match else 0 # Prepare scene data for animation scene_data = { "scene_number": scene_number, "title": request.scene_title, "scene_id": request.scene_id, } story_context = { "project_id": request.project_id, "type": "podcast", } animation_result = animate_scene_with_voiceover( image_bytes=image_bytes, audio_bytes=audio_bytes, scene_data=scene_data, story_context=story_context, user_id=user_id, resolution=request.resolution or "720p", prompt_override=request.prompt, image_mime="image/png", audio_mime="audio/mpeg", ) task_manager.update_task_status( task_id, "processing", progress=80.0, message="Saving video file..." ) base_dir = Path(__file__).parent.parent.parent.parent ai_video_dir = base_dir / "story_videos" / AI_VIDEO_SUBDIR ai_video_dir.mkdir(parents=True, exist_ok=True) video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir)) save_result = video_service.save_scene_video( video_bytes=animation_result["video_bytes"], scene_number=scene_number, user_id=user_id, ) video_filename = save_result["video_filename"] video_url = f"/api/story/videos/ai/{video_filename}" if auth_token: video_url = f"{video_url}?token={quote(auth_token)}" usage_info = track_video_usage( user_id=user_id, provider=animation_result["provider"], model_name=animation_result["model_name"], prompt=animation_result["prompt"], video_bytes=animation_result["video_bytes"], cost_override=animation_result["cost"], ) task_manager.update_task_status( task_id, "completed", progress=100.0, message="Video generation complete!", result={ "video_url": video_url, "video_filename": video_filename, "cost": animation_result["cost"], "duration": animation_result["duration"], "provider": animation_result["provider"], "model": animation_result["model_name"], }, ) logger.info( f"[Podcast] Video generation completed for project {request.project_id}, scene {request.scene_id}" ) except Exception as exc: logger.error(f"[Podcast] Video generation failed: {exc}", exc_info=True) task_manager.update_task_status( task_id, "failed", error=str(exc), message=f"Video generation failed: {exc}" ) @router.post("/render/video", response_model=PodcastVideoGenerationResponse) async def generate_podcast_video( request_obj: Request, request: PodcastVideoGenerationRequest, background_tasks: BackgroundTasks, current_user: Dict[str, Any] = Depends(get_current_user), ): """ Generate video for a podcast scene using WaveSpeed InfiniteTalk (avatar image + audio). Returns task_id for polling since InfiniteTalk can take up to 10 minutes. """ user_id = require_authenticated_user(current_user) logger.info( f"[Podcast] Starting video generation for project {request.project_id}, scene {request.scene_id}" ) # Load audio bytes audio_bytes = _load_audio_bytes_from_url(request.audio_url) # Load image bytes (use avatar if provided, otherwise generate default) if request.avatar_image_url: image_bytes = _load_image_bytes_from_url(request.avatar_image_url) else: # Generate a default avatar image or use a placeholder # For now, raise an error if no avatar is provided raise HTTPException( status_code=400, detail="Avatar image is required for video generation. Please upload an avatar image.", ) # Validate subscription limits db = next(get_db()) try: pricing_service = PricingService(db) validate_scene_animation_operation(pricing_service=pricing_service, user_id=user_id) finally: db.close() # Extract token for authenticated URL building auth_token = None auth_header = request_obj.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): auth_token = auth_header.replace("Bearer ", "").strip() # Create async task task_id = task_manager.create_task("podcast_video_generation") background_tasks.add_task( _execute_podcast_video_task, task_id=task_id, request=request, user_id=user_id, image_bytes=image_bytes, audio_bytes=audio_bytes, auth_token=auth_token, ) return PodcastVideoGenerationResponse( task_id=task_id, status="pending", message="Video generation started. This may take up to 10 minutes.", )