1610 lines
64 KiB
Python
1610 lines
64 KiB
Python
"""
|
|
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 services.content_asset_service import ContentAssetService
|
|
from models.content_asset_models import AssetType, AssetSource
|
|
from utils.logger_utils import get_service_logger
|
|
from utils.asset_tracker import save_asset_to_library
|
|
from services.story_writer.video_generation_service import StoryVideoGenerationService
|
|
from .task_manager import task_manager
|
|
from .handlers import avatar as avatar_handlers
|
|
from .handlers import images as image_handlers
|
|
from .handlers import audio as audio_handlers
|
|
|
|
router = APIRouter(prefix="/youtube", tags=["youtube"])
|
|
logger = get_service_logger("api.youtube")
|
|
|
|
# Video output and image directories
|
|
base_dir = Path(__file__).parent.parent.parent.parent
|
|
YOUTUBE_VIDEO_DIR = base_dir / "youtube_videos"
|
|
YOUTUBE_VIDEO_DIR.mkdir(parents=True, exist_ok=True)
|
|
YOUTUBE_AVATARS_DIR = base_dir / "youtube_avatars"
|
|
YOUTUBE_AVATARS_DIR.mkdir(parents=True, exist_ok=True)
|
|
YOUTUBE_IMAGES_DIR = base_dir / "youtube_images"
|
|
YOUTUBE_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Include sub-routers for avatar, images, and audio
|
|
router.include_router(avatar_handlers.router)
|
|
router.include_router(image_handlers.router)
|
|
router.include_router(audio_handlers.router)
|
|
|
|
|
|
# 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)"
|
|
)
|
|
video_type: Optional[str] = Field(
|
|
None,
|
|
pattern="^(tutorial|review|educational|entertainment|vlog|product_demo|reaction|storytelling)$",
|
|
description="Video format type: tutorial, review, educational, entertainment, vlog, product_demo, reaction, storytelling"
|
|
)
|
|
target_audience: Optional[str] = Field(
|
|
None,
|
|
description="Target audience description (helps optimize tone, pace, and style)"
|
|
)
|
|
video_goal: Optional[str] = Field(
|
|
None,
|
|
description="Primary goal of the video (educate, sell, entertain, etc.)"
|
|
)
|
|
brand_style: Optional[str] = Field(
|
|
None,
|
|
description="Brand visual aesthetic and style preferences"
|
|
)
|
|
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"
|
|
)
|
|
avatar_url: Optional[str] = Field(
|
|
None,
|
|
description="Optional avatar URL if user uploaded one before plan generation"
|
|
)
|
|
enable_research: Optional[bool] = Field(
|
|
True,
|
|
description="Enable Exa research to enhance plan with current information, trends, and better SEO keywords (default: True)"
|
|
)
|
|
|
|
|
|
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 SceneVideoRenderRequest(BaseModel):
|
|
"""Request model for rendering a single scene video."""
|
|
scene: Dict[str, Any] = Field(..., description="Single scene data to render")
|
|
video_plan: Dict[str, Any] = Field(..., description="Original video plan (context)")
|
|
resolution: str = Field("720p", pattern="^(480p|720p|1080p)$", description="Video resolution")
|
|
voice_id: str = Field("Wise_Woman", description="Voice ID for narration")
|
|
generate_audio_enabled: bool = Field(False, description="Whether to auto-generate audio if missing (default false)")
|
|
|
|
|
|
class SceneVideoRenderResponse(BaseModel):
|
|
"""Response model for single scene video rendering."""
|
|
success: bool
|
|
task_id: Optional[str] = None
|
|
message: str
|
|
scene_number: Optional[int] = None
|
|
|
|
|
|
class CombineVideosRequest(BaseModel):
|
|
"""Request model for combining multiple scene videos."""
|
|
video_urls: List[str] = Field(..., description="List of scene video URLs to combine in order")
|
|
video_plan: Optional[Dict[str, Any]] = Field(None, description="Original video plan (for metadata)")
|
|
resolution: str = Field("720p", pattern="^(480p|720p|1080p)$", description="Target resolution for output")
|
|
title: Optional[str] = Field(None, description="Optional title for the final video")
|
|
|
|
|
|
class CombineVideosResponse(BaseModel):
|
|
"""Response model for combine videos request."""
|
|
success: bool
|
|
task_id: Optional[str] = None
|
|
message: str
|
|
|
|
|
|
class VideoListResponse(BaseModel):
|
|
"""Response model for listing user videos."""
|
|
videos: List[Dict[str, Any]]
|
|
success: bool = True
|
|
message: str = "Videos fetched successfully"
|
|
|
|
|
|
class CombineVideosRequest(BaseModel):
|
|
"""Request model for combining multiple scene videos."""
|
|
scene_video_urls: List[str] = Field(..., description="List of scene video URLs to combine")
|
|
resolution: str = Field("720p", pattern="^(480p|720p|1080p)$", description="Output video resolution")
|
|
title: Optional[str] = Field(None, description="Optional title for the combined video")
|
|
|
|
|
|
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")
|
|
image_model: Optional[str] = Field("ideogram-v3-turbo", description="Image generation model")
|
|
|
|
|
|
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}"
|
|
)
|
|
|
|
# Note: Research subscription checks are handled by ResearchService internally
|
|
# ResearchService validates limits before making API calls and raises HTTPException(429) if exceeded
|
|
|
|
# Note: Subscription checks for LLM are handled by llm_text_gen internally
|
|
# It validates limits before making API calls and raises HTTPException(429) if exceeded
|
|
|
|
# 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 = await planner.generate_video_plan(
|
|
user_idea=request.user_idea,
|
|
duration_type=request.duration_type,
|
|
video_type=request.video_type,
|
|
target_audience=request.target_audience,
|
|
video_goal=request.video_goal,
|
|
brand_style=request.brand_style,
|
|
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
|
|
enable_research=getattr(request, 'enable_research', True), # Research enabled by default
|
|
)
|
|
|
|
# Auto-generate avatar if user didn't upload one
|
|
# Try to reuse existing avatar from asset library first to save on AI calls during testing
|
|
auto_avatar_url = None
|
|
if not request.avatar_url:
|
|
try:
|
|
from services.content_asset_service import ContentAssetService
|
|
from models.content_asset_models import AssetType, AssetSource
|
|
|
|
# Check for existing YouTube creator avatar in asset library
|
|
asset_service = ContentAssetService(db)
|
|
existing_avatars, _ = asset_service.get_user_assets(
|
|
user_id=user_id,
|
|
asset_type=AssetType.IMAGE,
|
|
source_module=AssetSource.YOUTUBE_CREATOR,
|
|
limit=1, # Get most recent one
|
|
)
|
|
|
|
if existing_avatars and len(existing_avatars) > 0:
|
|
# Reuse the most recent avatar
|
|
existing_avatar = existing_avatars[0]
|
|
auto_avatar_url = existing_avatar.file_url
|
|
plan["auto_generated_avatar_url"] = auto_avatar_url
|
|
plan["avatar_reused"] = True # Flag to indicate avatar was reused
|
|
logger.info(
|
|
f"[YouTubeAPI] ♻️ Reusing existing avatar from asset library to save AI call: {auto_avatar_url} "
|
|
f"(asset_id: {existing_avatar.id}, created: {existing_avatar.created_at})"
|
|
)
|
|
else:
|
|
# No existing avatar found, generate new one
|
|
import uuid
|
|
import json
|
|
from .handlers.avatar import _generate_avatar_from_context
|
|
# Pass both original user inputs AND plan data for better avatar generation
|
|
logger.info(f"[YouTubeAPI] 🎨 No existing avatar found, generating new avatar...")
|
|
avatar_response = await _generate_avatar_from_context(
|
|
user_id=user_id,
|
|
project_id=f"plan_{user_id}_{uuid.uuid4().hex[:8]}",
|
|
audience=request.target_audience or plan.get("target_audience"), # Prefer user input
|
|
content_type=request.video_type, # User's video type selection
|
|
video_plan_json=json.dumps(plan),
|
|
brand_style=request.brand_style, # User's brand style preference
|
|
db=db,
|
|
)
|
|
auto_avatar_url = avatar_response.get("avatar_url")
|
|
avatar_prompt = avatar_response.get("avatar_prompt")
|
|
plan["auto_generated_avatar_url"] = auto_avatar_url
|
|
plan["avatar_prompt"] = avatar_prompt # Store the AI prompt used for generation
|
|
plan["avatar_reused"] = False # Flag to indicate avatar was newly generated
|
|
logger.info(f"[YouTubeAPI] ✅ Auto-generated new avatar based on user inputs and plan: {auto_avatar_url}")
|
|
except Exception as e:
|
|
logger.warning(f"[YouTubeAPI] Avatar generation/reuse failed (non-critical): {e}")
|
|
# Non-critical, continue without avatar
|
|
|
|
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)
|
|
|
|
duration_type = request.video_plan.get('duration_type', 'medium')
|
|
has_existing_scenes = bool(request.video_plan.get("scenes")) and request.video_plan.get("_scenes_included")
|
|
|
|
logger.info(
|
|
f"[YouTubeAPI] Building scenes: duration={duration_type}, "
|
|
f"custom_script={bool(request.custom_script)}, "
|
|
f"has_existing_scenes={has_existing_scenes}, "
|
|
f"user={user_id}"
|
|
)
|
|
|
|
# Build scenes (optimized to reuse existing scenes if available)
|
|
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)")
|
|
|
|
# VALIDATION: Check for required assets (image and audio)
|
|
if not scene.get("imageUrl"):
|
|
validation_errors.append(f"Scene {scene_num}: Missing image. Please generate an image for this scene first.")
|
|
if not scene.get("audioUrl"):
|
|
validation_errors.append(f"Scene {scene_num}: Missing audio. Please generate audio narration for this scene first.")
|
|
|
|
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.post("/render/scene", response_model=SceneVideoRenderResponse)
|
|
async def render_single_scene_video(
|
|
request: SceneVideoRenderRequest,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> SceneVideoRenderResponse:
|
|
"""
|
|
Render a single scene video (scene-wise generation).
|
|
Returns a task_id for polling.
|
|
"""
|
|
try:
|
|
user_id = require_authenticated_user(current_user)
|
|
|
|
# Subscription validation (same as full render)
|
|
pricing_service = PricingService(db)
|
|
validate_scene_animation_operation(
|
|
pricing_service=pricing_service,
|
|
user_id=user_id
|
|
)
|
|
|
|
scene = request.scene
|
|
scene_num = scene.get("scene_number", 0)
|
|
|
|
# Pre-validation to avoid wasted calls
|
|
validation_errors = []
|
|
visual_prompt = (scene.get("enhanced_visual_prompt") or scene.get("visual_prompt", "")).strip()
|
|
duration = scene.get("duration_estimate", 5)
|
|
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)")
|
|
if duration < 1 or duration > 10:
|
|
validation_errors.append(f"Scene {scene_num}: Invalid duration ({duration}s, must be 1-10 seconds)")
|
|
if not scene.get("imageUrl"):
|
|
validation_errors.append(f"Scene {scene_num}: Missing image. Please generate an image first.")
|
|
if not scene.get("audioUrl") and not request.generate_audio_enabled:
|
|
validation_errors.append(f"Scene {scene_num}: Missing audio. Please generate audio first or enable generate_audio_enabled.")
|
|
|
|
if validation_errors:
|
|
error_msg = "Validation failed: " + "; ".join(validation_errors)
|
|
logger.warning(f"[YouTubeAPI] {error_msg}")
|
|
return SceneVideoRenderResponse(
|
|
success=False,
|
|
task_id=None,
|
|
message=error_msg,
|
|
scene_number=scene_num
|
|
)
|
|
|
|
# Create task
|
|
task_id = task_manager.create_task("youtube_scene_video_render")
|
|
logger.info(
|
|
f"[YouTubeAPI] Created single-scene render task {task_id} for user {user_id}, scene={scene_num}, resolution={request.resolution}"
|
|
)
|
|
|
|
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 SceneVideoRenderResponse(
|
|
success=False,
|
|
task_id=None,
|
|
message="Failed to create render task. Please try again.",
|
|
scene_number=scene_num
|
|
)
|
|
|
|
# Add background task
|
|
try:
|
|
background_tasks.add_task(
|
|
_execute_scene_video_render_task,
|
|
task_id=task_id,
|
|
scene=scene,
|
|
video_plan=request.video_plan,
|
|
user_id=user_id,
|
|
resolution=request.resolution,
|
|
generate_audio_enabled=request.generate_audio_enabled,
|
|
voice_id=request.voice_id,
|
|
)
|
|
logger.info(f"[YouTubeAPI] Background task added for single scene {task_id}")
|
|
except Exception as bg_error:
|
|
logger.error(f"[YouTubeAPI] Failed to add background task for {task_id}: {bg_error}", exc_info=True)
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"failed",
|
|
error=str(bg_error),
|
|
message="Failed to start background render task"
|
|
)
|
|
return SceneVideoRenderResponse(
|
|
success=False,
|
|
task_id=None,
|
|
message=f"Failed to start render task: {str(bg_error)}",
|
|
scene_number=scene_num
|
|
)
|
|
|
|
return SceneVideoRenderResponse(
|
|
success=True,
|
|
task_id=task_id,
|
|
message=f"Scene {scene_num} rendering started.",
|
|
scene_number=scene_num
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"[YouTubeAPI] Error starting single-scene render: {e}", exc_info=True)
|
|
return SceneVideoRenderResponse(
|
|
success=False,
|
|
task_id=None,
|
|
message=f"Failed to start scene render: {str(e)}",
|
|
scene_number=request.scene.get("scene_number") if request and request.scene else None
|
|
)
|
|
|
|
|
|
@router.get("/render/{task_id}")
|
|
async def get_render_status(
|
|
task_id: str,
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get the status of a video rendering task.
|
|
|
|
Returns current progress, status, and result when complete.
|
|
Returns None if task not found (matches podcast pattern for graceful handling).
|
|
"""
|
|
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:
|
|
# Log at DEBUG level - null is expected when tasks expire or server restarts
|
|
# This prevents log spam from frontend polling for expired/completed tasks
|
|
# Return None instead of raising 404 to match podcast pattern for graceful frontend handling
|
|
logger.debug(
|
|
f"[YouTubeAPI] Task {task_id} not found (may have expired or been cleaned up). "
|
|
f"Available tasks: {len(task_manager.task_storage)}"
|
|
)
|
|
return None
|
|
|
|
return task_status
|
|
|
|
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)}"
|
|
)
|
|
|
|
|
|
@router.post("/render/combine", response_model=VideoRenderResponse)
|
|
async def combine_videos(
|
|
request: CombineVideosRequest,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> VideoRenderResponse:
|
|
"""
|
|
Combine multiple scene videos into a final video.
|
|
Returns task_id for polling.
|
|
"""
|
|
try:
|
|
user_id = require_authenticated_user(current_user)
|
|
|
|
# Subscription validation
|
|
pricing_service = PricingService(db)
|
|
validate_scene_animation_operation(
|
|
pricing_service=pricing_service,
|
|
user_id=user_id
|
|
)
|
|
|
|
if not request.scene_video_urls or len(request.scene_video_urls) < 2:
|
|
return VideoRenderResponse(
|
|
success=False,
|
|
message="At least two scene videos are required to combine."
|
|
)
|
|
|
|
task_id = task_manager.create_task("youtube_combine_video")
|
|
logger.info(
|
|
f"[YouTubeAPI] Created combine task {task_id} for user {user_id}, videos={len(request.scene_video_urls)}, resolution={request.resolution}"
|
|
)
|
|
|
|
initial_status = task_manager.get_task_status(task_id)
|
|
if not initial_status:
|
|
logger.error(f"[YouTubeAPI] Failed to create combine task {task_id} - task not found immediately after creation")
|
|
return VideoRenderResponse(
|
|
success=False,
|
|
message="Failed to create combine task. Please try again."
|
|
)
|
|
|
|
try:
|
|
background_tasks.add_task(
|
|
_execute_combine_video_task,
|
|
task_id=task_id,
|
|
scene_video_urls=request.scene_video_urls,
|
|
user_id=user_id,
|
|
resolution=request.resolution,
|
|
title=request.title,
|
|
)
|
|
logger.info(f"[YouTubeAPI] Background combine task added for {task_id}")
|
|
except Exception as bg_error:
|
|
logger.error(f"[YouTubeAPI] Failed to add combine background task for {task_id}: {bg_error}", exc_info=True)
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"failed",
|
|
error=str(bg_error),
|
|
message="Failed to start combine task"
|
|
)
|
|
return VideoRenderResponse(
|
|
success=False,
|
|
message=f"Failed to start combine task: {str(bg_error)}"
|
|
)
|
|
|
|
return VideoRenderResponse(
|
|
success=True,
|
|
task_id=task_id,
|
|
message="Video combination started."
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"[YouTubeAPI] Error starting combine: {e}", exc_info=True)
|
|
return VideoRenderResponse(
|
|
success=False,
|
|
message=f"Failed to start combine: {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}",
|
|
)
|
|
|
|
|
|
def _execute_scene_video_render_task(
|
|
task_id: str,
|
|
scene: Dict[str, Any],
|
|
video_plan: Dict[str, Any],
|
|
user_id: str,
|
|
resolution: str,
|
|
generate_audio_enabled: bool,
|
|
voice_id: str,
|
|
):
|
|
"""Background task to render a single scene video (scene-wise generation)."""
|
|
scene_num = scene.get("scene_number", 0)
|
|
logger.info(
|
|
f"[YouTubeRenderer] Background single-scene task started for task {task_id}, scene={scene_num}, user={user_id}"
|
|
)
|
|
|
|
task_status = task_manager.get_task_status(task_id)
|
|
if not task_status:
|
|
logger.error(
|
|
f"[YouTubeRenderer] Task {task_id} not found when single-scene task started."
|
|
)
|
|
return
|
|
|
|
try:
|
|
task_manager.update_task_status(
|
|
task_id, "processing", progress=5.0, message=f"Rendering scene {scene_num}..."
|
|
)
|
|
|
|
renderer = YouTubeVideoRendererService()
|
|
|
|
scene_result = renderer.render_scene_video(
|
|
scene=scene,
|
|
video_plan=video_plan,
|
|
user_id=user_id,
|
|
resolution=resolution,
|
|
generate_audio_enabled=generate_audio_enabled,
|
|
voice_id=voice_id,
|
|
)
|
|
|
|
total_cost = scene_result.get("cost", 0.0) or 0.0
|
|
result = {
|
|
"scene_results": [scene_result],
|
|
"failed_scenes": [],
|
|
"total_cost": total_cost,
|
|
"final_video_url": scene_result.get("video_url"),
|
|
"num_successful": 1,
|
|
"num_failed": 0,
|
|
"resolution": resolution,
|
|
"partial_success": False,
|
|
"scene_number": scene_num,
|
|
"video_url": scene_result.get("video_url"),
|
|
"video_filename": scene_result.get("video_filename"),
|
|
}
|
|
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"completed",
|
|
progress=100.0,
|
|
message=f"Scene {scene_num} rendered successfully",
|
|
result=result,
|
|
)
|
|
|
|
# Verify the task status was updated correctly (matches podcast pattern)
|
|
updated_status = task_manager.get_task_status(task_id)
|
|
logger.info(
|
|
f"[YouTubeRenderer] 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"[YouTubeRenderer] ✅ Single-scene render {task_id} completed (scene {scene_num}), 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] Single-scene task {task_id} failed: {error_msg}")
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"failed",
|
|
error=error_msg,
|
|
message=f"Scene {scene_num} rendering failed: {error_msg}",
|
|
)
|
|
except Exception as exc:
|
|
error_msg = str(exc)
|
|
logger.error(f"[YouTubeRenderer] Single-scene task {task_id} error: {error_msg}", exc_info=True)
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"failed",
|
|
error=error_msg,
|
|
message=f"Scene {scene_num} rendering error: {error_msg}",
|
|
)
|
|
|
|
|
|
@router.post("/render/combine", response_model=CombineVideosResponse)
|
|
async def combine_scene_videos(
|
|
request: CombineVideosRequest,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> CombineVideosResponse:
|
|
"""
|
|
Combine multiple scene videos into a final video.
|
|
Returns task_id for polling.
|
|
"""
|
|
try:
|
|
user_id = require_authenticated_user(current_user)
|
|
|
|
# Subscription validation (reuse scene animation check)
|
|
pricing_service = PricingService(db)
|
|
validate_scene_animation_operation(
|
|
pricing_service=pricing_service,
|
|
user_id=user_id
|
|
)
|
|
|
|
if not request.video_urls or len(request.video_urls) < 2:
|
|
return CombineVideosResponse(
|
|
success=False,
|
|
task_id=None,
|
|
message="At least two videos are required to combine."
|
|
)
|
|
|
|
# Pre-validate that referenced video files exist and are within youtube_videos dir
|
|
base_dir = Path(__file__).parent.parent.parent.parent
|
|
youtube_video_dir = base_dir / "youtube_videos"
|
|
missing_files = []
|
|
for url in request.video_urls:
|
|
filename = Path(url).name # strips query params if present
|
|
video_path = youtube_video_dir / filename
|
|
# prevent directory traversal
|
|
if ".." in filename or "/" in filename or "\\" in filename:
|
|
return CombineVideosResponse(
|
|
success=False,
|
|
task_id=None,
|
|
message=f"Invalid video filename: {filename}"
|
|
)
|
|
if not video_path.exists():
|
|
missing_files.append(filename)
|
|
if missing_files:
|
|
return CombineVideosResponse(
|
|
success=False,
|
|
task_id=None,
|
|
message=f"Video files not found for combine: {', '.join(missing_files)}"
|
|
)
|
|
|
|
# Create task
|
|
task_id = task_manager.create_task("youtube_video_combine")
|
|
logger.info(
|
|
f"[YouTubeAPI] Created combine task {task_id} for user {user_id}, videos={len(request.video_urls)}, resolution={request.resolution}"
|
|
)
|
|
|
|
initial_status = task_manager.get_task_status(task_id)
|
|
if not initial_status:
|
|
logger.error(f"[YouTubeAPI] Failed to create combine task {task_id} - task not found immediately after creation")
|
|
return CombineVideosResponse(
|
|
success=False,
|
|
task_id=None,
|
|
message="Failed to create combine task. Please try again."
|
|
)
|
|
|
|
# Background combine task
|
|
try:
|
|
background_tasks.add_task(
|
|
_execute_combine_video_task,
|
|
task_id=task_id,
|
|
scene_video_urls=request.video_urls,
|
|
user_id=user_id,
|
|
resolution=request.resolution,
|
|
title=request.title,
|
|
)
|
|
logger.info(f"[YouTubeAPI] Background combine task added for task {task_id}")
|
|
except Exception as bg_error:
|
|
logger.error(f"[YouTubeAPI] Failed to add combine task {task_id}: {bg_error}", exc_info=True)
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"failed",
|
|
error=str(bg_error),
|
|
message="Failed to start video combination task"
|
|
)
|
|
return CombineVideosResponse(
|
|
success=False,
|
|
task_id=None,
|
|
message=f"Failed to start combination task: {str(bg_error)}"
|
|
)
|
|
|
|
return CombineVideosResponse(
|
|
success=True,
|
|
task_id=task_id,
|
|
message=f"Combining {len(request.video_urls)} videos...",
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"[YouTubeAPI] Error combining videos: {e}", exc_info=True)
|
|
return CombineVideosResponse(
|
|
success=False,
|
|
task_id=None,
|
|
message=f"Failed to start video combination: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/videos", response_model=VideoListResponse)
|
|
async def list_videos(
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> VideoListResponse:
|
|
"""
|
|
List videos for the current user from the asset library (source: youtube_creator).
|
|
Used to rescue/persist scene videos after reloads.
|
|
"""
|
|
try:
|
|
user_id = require_authenticated_user(current_user)
|
|
asset_service = ContentAssetService(db)
|
|
|
|
assets, _ = asset_service.get_user_assets(
|
|
user_id=user_id,
|
|
asset_type=AssetType.VIDEO,
|
|
source_module=AssetSource.YOUTUBE_CREATOR,
|
|
limit=100,
|
|
)
|
|
|
|
videos = []
|
|
for asset in assets:
|
|
try:
|
|
videos.append({
|
|
"scene_number": asset.asset_metadata.get("scene_number") if asset.asset_metadata else None,
|
|
"video_url": asset.file_url,
|
|
"filename": asset.filename,
|
|
"created_at": asset.created_at.isoformat() if asset.created_at else None,
|
|
"resolution": asset.asset_metadata.get("resolution") if asset.asset_metadata else None,
|
|
})
|
|
except Exception as asset_error:
|
|
logger.warning(f"[YouTubeAPI] Error processing asset {asset.id if hasattr(asset, 'id') else 'unknown'}: {asset_error}")
|
|
continue # Skip this asset and continue with others
|
|
|
|
logger.info(f"[YouTubeAPI] Listed {len(videos)} videos for user {user_id}")
|
|
return VideoListResponse(videos=videos)
|
|
except Exception as e:
|
|
logger.error(f"[YouTubeAPI] Error listing videos: {e}", exc_info=True)
|
|
# Return empty list on error rather than failing completely
|
|
return VideoListResponse(videos=[], success=False, message=f"Failed to list videos: {str(e)}")
|
|
|
|
|
|
def _execute_combine_video_task(
|
|
task_id: str,
|
|
scene_video_urls: List[str],
|
|
user_id: str,
|
|
resolution: str,
|
|
title: Optional[str],
|
|
):
|
|
"""Background task to combine multiple scene videos into one final video."""
|
|
logger.info(
|
|
f"[YouTubeRenderer] Background combine task started for task {task_id}, videos={len(scene_video_urls)}, user={user_id}"
|
|
)
|
|
|
|
task_status = task_manager.get_task_status(task_id)
|
|
if not task_status:
|
|
logger.error(f"[YouTubeRenderer] Task {task_id} not found when combine task started.")
|
|
return
|
|
|
|
base_dir = Path(__file__).parent.parent.parent.parent
|
|
youtube_video_dir = base_dir / "youtube_videos"
|
|
|
|
try:
|
|
task_manager.update_task_status(
|
|
task_id, "processing", progress=5.0, message="Preparing to combine videos..."
|
|
)
|
|
|
|
# Resolve video paths from URLs
|
|
video_paths: List[Path] = []
|
|
for url in scene_video_urls:
|
|
filename = Path(url).name
|
|
video_path = youtube_video_dir / filename
|
|
if not video_path.exists():
|
|
logger.error(f"[YouTubeRenderer] Video file not found for combine: {video_path}")
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Video file not found: {filename}",
|
|
)
|
|
video_paths.append(video_path)
|
|
|
|
if len(video_paths) < 2:
|
|
raise HTTPException(status_code=400, detail="Need at least two videos to combine.")
|
|
|
|
task_manager.update_task_status(
|
|
task_id, "processing", progress=25.0, message="Combining scene videos..."
|
|
)
|
|
|
|
video_service = StoryVideoGenerationService(output_dir=str(youtube_video_dir))
|
|
combined_result = video_service.generate_story_video(
|
|
scenes=[
|
|
{"scene_number": idx + 1, "title": f"Scene {idx + 1}"}
|
|
for idx in range(len(video_paths))
|
|
],
|
|
image_paths=[None] * len(video_paths),
|
|
audio_paths=[],
|
|
video_paths=[str(p) for p in video_paths],
|
|
user_id=user_id,
|
|
story_title=title or "YouTube Video",
|
|
fps=24,
|
|
)
|
|
|
|
task_manager.update_task_status(
|
|
task_id, "processing", progress=90.0, message="Finalizing combined video..."
|
|
)
|
|
|
|
final_path = combined_result["video_path"]
|
|
final_url = combined_result["video_url"]
|
|
file_size = combined_result.get("file_size", 0)
|
|
|
|
# Save to asset library
|
|
try:
|
|
db = next(get_db())
|
|
try:
|
|
save_asset_to_library(
|
|
db=db,
|
|
user_id=user_id,
|
|
asset_type="video",
|
|
source_module="youtube_creator",
|
|
filename=Path(final_path).name,
|
|
file_url=final_url,
|
|
file_path=str(final_path),
|
|
file_size=file_size,
|
|
mime_type="video/mp4",
|
|
title=title or "YouTube Video",
|
|
description="Combined YouTube creator video",
|
|
tags=["youtube_creator", "video", "combined", resolution],
|
|
provider="wavespeed",
|
|
model="alibaba/wan-2.5/text-to-video",
|
|
cost=0.0,
|
|
asset_metadata={
|
|
"resolution": resolution,
|
|
"status": "completed",
|
|
"scene_count": len(video_paths),
|
|
},
|
|
)
|
|
finally:
|
|
db.close()
|
|
except Exception as e:
|
|
logger.warning(f"[YouTubeRenderer] Failed to save combined video to asset library: {e}")
|
|
|
|
result = {
|
|
"video_url": final_url,
|
|
"video_path": final_path,
|
|
"resolution": resolution,
|
|
"scene_count": len(video_paths),
|
|
}
|
|
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"completed",
|
|
progress=100.0,
|
|
message="Combined video generated successfully",
|
|
result=result,
|
|
)
|
|
|
|
logger.info(
|
|
f"[YouTubeRenderer] ✅ Combine task {task_id} completed, scenes={len(video_paths)}"
|
|
)
|
|
|
|
except HTTPException as exc:
|
|
error_msg = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
|
logger.error(f"[YouTubeRenderer] Combine task {task_id} failed: {error_msg}")
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"failed",
|
|
error=error_msg,
|
|
message=f"Combine failed: {error_msg}",
|
|
)
|
|
except Exception as exc:
|
|
error_msg = str(exc)
|
|
logger.error(f"[YouTubeRenderer] Combine task {task_id} error: {error_msg}", exc_info=True)
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"failed",
|
|
error=error_msg,
|
|
message=f"Combine 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,
|
|
image_model=request.image_model,
|
|
)
|
|
|
|
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)}"
|
|
)
|
|
|