""" Podcast Video Handlers Video generation and serving endpoints. """ from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Request from fastapi.responses import FileResponse from sqlalchemy.orm import Session from typing import Dict, Any, Optional from pathlib import Path from urllib.parse import quote import re import json from concurrent.futures import ThreadPoolExecutor from services.database import get_db from middleware.auth_middleware import get_current_user, get_current_user_with_query_token from api.story_writer.utils.auth import require_authenticated_user from services.wavespeed.infinitetalk import animate_scene_with_voiceover from services.podcast.video_combination_service import PodcastVideoCombinationService 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 loguru import logger from ..constants import AI_VIDEO_SUBDIR, PODCAST_VIDEOS_DIR from ..utils import load_podcast_audio_bytes, load_podcast_image_bytes from services.podcast_service import PodcastService from ..models import ( PodcastVideoGenerationRequest, PodcastVideoGenerationResponse, PodcastCombineVideosRequest, PodcastCombineVideosResponse, ) router = APIRouter() # Thread pool executor for CPU-intensive video operations # This prevents blocking the FastAPI event loop _video_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="podcast_video") def _extract_error_message(exc: Exception) -> str: """ Extract user-friendly error message from exception. Handles HTTPException with nested error details from WaveSpeed API. """ if isinstance(exc, HTTPException): detail = exc.detail # If detail is a dict (from WaveSpeed client) if isinstance(detail, dict): # Try to extract message from nested response JSON response_str = detail.get("response", "") if response_str: try: response_json = json.loads(response_str) if isinstance(response_json, dict) and "message" in response_json: return response_json["message"] except (json.JSONDecodeError, TypeError): pass # Fall back to error field if "error" in detail: return detail["error"] # If detail is a string elif isinstance(detail, str): return detail # For other exceptions, use string representation error_str = str(exc) # Try to extract meaningful message from HTTPException string format # Format: "502: {'error': '...', 'response': '{"message":"..."}'}" if "Insufficient credits" in error_str or "insufficient credits" in error_str.lower(): return "Insufficient WaveSpeed credits. Please top up your account." # Try to extract JSON message from string try: # Look for JSON-like structures in the error string json_match = re.search(r'"message"\s*:\s*"([^"]+)"', error_str) if json_match: return json_match.group(1) except Exception: pass return error_str def _execute_podcast_video_task( task_id: str, request: PodcastVideoGenerationRequest, user_id: str, image_bytes: bytes, audio_bytes: bytes, auth_token: Optional[str] = None, mask_image_bytes: Optional[bytes] = 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, mask_image_bytes=mask_image_bytes, seed=request.seed if request.seed is not None else -1, image_mime="image/png", audio_mime="audio/mpeg", ) task_manager.update_task_status( task_id, "processing", progress=80.0, message="Saving video file..." ) # Use podcast-specific video directory ai_video_dir = PODCAST_VIDEOS_DIR / AI_VIDEO_SUBDIR ai_video_dir.mkdir(parents=True, exist_ok=True) video_service = PodcastVideoCombinationService(output_dir=str(PODCAST_VIDEOS_DIR / "Final_Videos")) 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/podcast/videos/{video_filename}" if auth_token: video_url = f"{video_url}?token={quote(auth_token)}" logger.info( f"[Podcast] Video saved: filename={video_filename}, url={video_url}, scene={request.scene_id}" ) 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"], ) result_data = { "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] Updating task status to completed: task_id={task_id}, result={result_data}" ) task_manager.update_task_status( task_id, "completed", progress=100.0, message="Video generation complete!", result=result_data, ) # Verify the task status was updated correctly updated_status = task_manager.get_task_status(task_id) logger.info( f"[Podcast] Task status after update: task_id={task_id}, status={updated_status.get('status') if updated_status else 'None'}, has_result={bool(updated_status.get('result') if updated_status else False)}, video_url={updated_status.get('result', {}).get('video_url') if updated_status else 'N/A'}" ) logger.info( f"[Podcast] Video generation completed for project {request.project_id}, scene {request.scene_id}" ) except Exception as exc: # Use logger.exception to avoid KeyError when exception message contains curly braces logger.exception(f"[Podcast] Video generation failed for project {request.project_id}, scene {request.scene_id}") # Extract user-friendly error message from exception error_msg = _extract_error_message(exc) task_manager.update_task_status( task_id, "failed", error=error_msg, message=f"Video generation failed: {error_msg}" ) @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_podcast_audio_bytes(request.audio_url) # Validate resolution if request.resolution not in {"480p", "720p"}: raise HTTPException(status_code=400, detail="Resolution must be '480p' or '720p'.") # Load image bytes (scene image is required for video generation) if request.avatar_image_url: image_bytes = load_podcast_image_bytes(request.avatar_image_url) else: # Scene-specific image should be generated before video generation raise HTTPException( status_code=400, detail="Scene image is required for video generation. Please generate images for scenes first.", ) mask_image_bytes = None if request.mask_image_url: try: mask_image_bytes = load_podcast_image_bytes(request.mask_image_url) except Exception as e: logger.error(f"[Podcast] Failed to load mask image: {e}") raise HTTPException( status_code=400, detail="Failed to load mask image for video generation.", ) # 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, mask_image_bytes=mask_image_bytes, ) return PodcastVideoGenerationResponse( task_id=task_id, status="pending", message="Video generation started. This may take up to 10 minutes.", ) @router.get("/videos/{filename}") async def serve_podcast_video( filename: str, current_user: Dict[str, Any] = Depends(get_current_user_with_query_token), ): """Serve generated podcast scene video files. Supports authentication via Authorization header or token query parameter. Query parameter is useful for HTML elements like