WIP: AI Podcast Maker and YouTube Creator Studio integration

This commit is contained in:
ajaysi
2025-12-10 09:37:55 +05:30
parent 31f078c763
commit 81590cf4db
75 changed files with 11879 additions and 1380 deletions

View File

@@ -3,7 +3,7 @@ Content Assets API Router
API endpoints for managing unified content assets across all modules.
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Body
from sqlalchemy.orm import Session
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
@@ -118,6 +118,79 @@ async def get_assets(
raise HTTPException(status_code=500, detail=f"Error fetching assets: {str(e)}")
class AssetCreateRequest(BaseModel):
"""Request model for creating a new asset."""
asset_type: str = Field(..., description="Asset type: text, image, video, or audio")
source_module: str = Field(..., description="Source module that generated the asset")
filename: str = Field(..., description="Original filename")
file_url: str = Field(..., description="Public URL to access the asset")
file_path: Optional[str] = Field(None, description="Server file path (optional)")
file_size: Optional[int] = Field(None, description="File size in bytes")
mime_type: Optional[str] = Field(None, description="MIME type")
title: Optional[str] = Field(None, description="Asset title")
description: Optional[str] = Field(None, description="Asset description")
prompt: Optional[str] = Field(None, description="Generation prompt")
tags: Optional[List[str]] = Field(default_factory=list, description="List of tags")
asset_metadata: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional metadata")
provider: Optional[str] = Field(None, description="AI provider used")
model: Optional[str] = Field(None, description="Model used")
cost: Optional[float] = Field(0.0, description="Generation cost")
generation_time: Optional[float] = Field(None, description="Generation time in seconds")
@router.post("/", response_model=AssetResponse)
async def create_asset(
asset_data: AssetCreateRequest,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Create a new content asset."""
try:
user_id = current_user.get("user_id") or current_user.get("id")
if not user_id:
raise HTTPException(status_code=401, detail="User ID not found")
# Validate asset type
try:
asset_type_enum = AssetType(asset_data.asset_type.lower())
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid asset type: {asset_data.asset_type}")
# Validate source module
try:
source_module_enum = AssetSource(asset_data.source_module.lower())
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid source module: {asset_data.source_module}")
service = ContentAssetService(db)
asset = service.create_asset(
user_id=user_id,
asset_type=asset_type_enum,
source_module=source_module_enum,
filename=asset_data.filename,
file_url=asset_data.file_url,
file_path=asset_data.file_path,
file_size=asset_data.file_size,
mime_type=asset_data.mime_type,
title=asset_data.title,
description=asset_data.description,
prompt=asset_data.prompt,
tags=asset_data.tags or [],
asset_metadata=asset_data.asset_metadata or {},
provider=asset_data.provider,
model=asset_data.model,
cost=asset_data.cost,
generation_time=asset_data.generation_time,
)
return AssetResponse.model_validate(asset)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error creating asset: {str(e)}")
@router.post("/{asset_id}/favorite", response_model=Dict[str, Any])
async def toggle_favorite(
asset_id: int,

View File

@@ -40,22 +40,16 @@ from ...utils.constants import ERROR_MESSAGES, SUCCESS_MESSAGES
# Removed old service import - using orchestrator only
from ...services.calendar_generation_service import CalendarGenerationService
# Import for preflight checks
from services.subscription.preflight_validator import validate_calendar_generation_operations
from services.subscription.pricing_service import PricingService
from models.onboarding import OnboardingSession
from models.content_planning import ContentStrategy
# Create router
router = APIRouter(prefix="/calendar-generation", tags=["calendar-generation"])
# Helper function to convert Clerk user ID to integer
def get_user_id_int(clerk_user_id: str) -> int:
"""
Convert Clerk user ID string to integer for database compatibility.
Uses consistent hashing to ensure same user always gets same ID.
"""
try:
# Try to extract numeric portion from Clerk ID format (user_XXXX)
numeric_part = clerk_user_id.replace('user_', '').replace('-', '')[:8]
return int(numeric_part, 16) % 2147483647
except:
# Fallback to hash if extraction fails
return hash(clerk_user_id) % 2147483647
# Helper function removed - using Clerk ID string directly
@router.post("/generate-calendar", response_model=CalendarGenerationResponse)
async def generate_comprehensive_calendar(
@@ -71,15 +65,36 @@ async def generate_comprehensive_calendar(
try:
# Use authenticated user ID instead of request user ID for security
clerk_user_id = str(current_user.get('id'))
user_id_int = get_user_id_int(clerk_user_id)
logger.info(f"🎯 Generating comprehensive calendar for authenticated user {clerk_user_id} (int: {user_id_int})")
logger.info(f"🎯 Generating comprehensive calendar for authenticated user {clerk_user_id}")
# Preflight Checks
# 1. Check Onboarding Data
onboarding = db.query(OnboardingSession).filter(OnboardingSession.user_id == clerk_user_id).first()
if not onboarding:
raise HTTPException(status_code=400, detail="Onboarding data not found. Please complete onboarding first.")
# 2. Check Strategy (if provided)
if request.strategy_id:
# Assuming migration to string user_id
# Note: If migration hasn't run for ContentStrategy, this might fail if user_id column is Integer.
# But we are proceeding with the assumption of full string ID support.
strategy = db.query(ContentStrategy).filter(ContentStrategy.id == request.strategy_id).first()
if not strategy:
raise HTTPException(status_code=404, detail="Content Strategy not found.")
# Verify ownership
if str(strategy.user_id) != clerk_user_id:
raise HTTPException(status_code=403, detail="Not authorized to access this strategy.")
# 3. Subscription/Limits Check
pricing_service = PricingService(db)
validate_calendar_generation_operations(pricing_service, clerk_user_id)
# Initialize service with database session for active strategy access
calendar_service = CalendarGenerationService(db)
calendar_data = await calendar_service.generate_comprehensive_calendar(
user_id=user_id_int, # Use authenticated user ID
user_id=clerk_user_id, # Use authenticated user ID string
strategy_id=request.strategy_id,
calendar_type=request.calendar_type,
industry=request.industry,
@@ -222,15 +237,14 @@ async def get_trending_topics(
try:
# Use authenticated user ID instead of query parameter for security
clerk_user_id = str(current_user.get('id'))
user_id = get_user_id_int(clerk_user_id)
logger.info(f"📈 Getting trending topics for authenticated user {clerk_user_id} (int: {user_id}) in {industry}")
logger.info(f"📈 Getting trending topics for authenticated user {clerk_user_id} in {industry}")
# Initialize service with database session for active strategy access
calendar_service = CalendarGenerationService(db)
result = await calendar_service.get_trending_topics(
user_id=user_id,
user_id=clerk_user_id,
industry=industry,
limit=limit
)
@@ -257,9 +271,8 @@ async def get_comprehensive_user_data(
try:
# Use authenticated user ID instead of query parameter for security
clerk_user_id = str(current_user.get('id'))
user_id = get_user_id_int(clerk_user_id)
logger.info(f"Getting comprehensive user data for authenticated user {clerk_user_id} (int: {user_id}, force_refresh={force_refresh})")
logger.info(f"Getting comprehensive user data for authenticated user {clerk_user_id} (force_refresh={force_refresh})")
# Initialize cache service
from services.comprehensive_user_data_cache_service import ComprehensiveUserDataCacheService
@@ -267,7 +280,7 @@ async def get_comprehensive_user_data(
# Get data with caching
data, is_cached = await cache_service.get_cached_data(
user_id, None, force_refresh=force_refresh
clerk_user_id, None, force_refresh=force_refresh
)
if not data:
@@ -285,11 +298,11 @@ async def get_comprehensive_user_data(
"message": f"Comprehensive user data retrieved successfully (cache: {'HIT' if is_cached else 'MISS'})"
}
logger.info(f"Successfully retrieved comprehensive user data for user_id: {user_id} (cache: {'HIT' if is_cached else 'MISS'})")
logger.info(f"Successfully retrieved comprehensive user data for user_id: {clerk_user_id} (cache: {'HIT' if is_cached else 'MISS'})")
return result
except Exception as e:
logger.error(f"Error getting comprehensive user data for user_id {user_id}: {str(e)}")
logger.error(f"Error getting comprehensive user data for user_id {clerk_user_id}: {str(e)}")
logger.error(f"Exception type: {type(e)}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
@@ -373,18 +386,17 @@ async def start_calendar_generation(
try:
# Use authenticated user ID instead of request user ID for security
clerk_user_id = str(current_user.get('id'))
user_id_int = get_user_id_int(clerk_user_id)
logger.info(f"🎯 Starting calendar generation for authenticated user {clerk_user_id} (int: {user_id_int})")
logger.info(f"🎯 Starting calendar generation for authenticated user {clerk_user_id}")
# Initialize service with database session for active strategy access
calendar_service = CalendarGenerationService(db)
# Check if user already has an active session
existing_session = calendar_service._get_active_session_for_user(user_id_int)
existing_session = calendar_service._get_active_session_for_user(clerk_user_id)
if existing_session:
logger.info(f"🔄 User {user_id_int} already has active session: {existing_session}")
logger.info(f"🔄 User {clerk_user_id} already has active session: {existing_session}")
return {
"session_id": existing_session,
"status": "existing",
@@ -397,7 +409,7 @@ async def start_calendar_generation(
# Update request data with authenticated user ID
request_dict = request.dict()
request_dict['user_id'] = user_id_int # Override with authenticated user ID
request_dict['user_id'] = clerk_user_id # Override with authenticated user ID
# Initialize orchestrator session
success = calendar_service.initialize_orchestrator_session(session_id, request_dict)
@@ -464,7 +476,7 @@ async def get_cache_stats(db: Session = Depends(get_db)) -> Dict[str, Any]:
@router.delete("/cache/invalidate/{user_id}")
async def invalidate_user_cache(
user_id: int,
user_id: str,
strategy_id: Optional[int] = Query(None, description="Strategy ID to invalidate (optional)"),
db: Session = Depends(get_db)
) -> Dict[str, Any]:

View File

@@ -26,6 +26,10 @@ from ..utils.error_handlers import ContentPlanningErrorHandler
from ..utils.response_builders import ResponseBuilder
from ..utils.constants import ERROR_MESSAGES, SUCCESS_MESSAGES
# Import models for persistence
from models.enhanced_calendar_models import CalendarGenerationSession
from models.content_planning import CalendarEvent, ContentStrategy
class CalendarGenerationService:
"""Service class for calendar generation operations."""
@@ -42,7 +46,7 @@ class CalendarGenerationService:
logger.error(f"❌ Failed to initialize orchestrator: {e}")
self.orchestrator = None
async def generate_comprehensive_calendar(self, user_id: int, strategy_id: Optional[int] = None,
async def generate_comprehensive_calendar(self, user_id: str, strategy_id: Optional[int] = None,
calendar_type: str = "monthly", industry: Optional[str] = None,
business_size: str = "sme") -> Dict[str, Any]:
"""Generate a comprehensive AI-powered content calendar using the 12-step orchestrator."""
@@ -79,6 +83,10 @@ class CalendarGenerationService:
if progress and progress.get("status") == "completed":
calendar_data = progress.get("step_results", {}).get("step_12", {}).get("result", {})
processing_time = time.time() - start_time
# Save to database
await self._save_calendar_to_db(user_id, strategy_id, calendar_data, session_id)
logger.info(f"✅ Calendar generated successfully in {processing_time:.2f}s")
return calendar_data
elif progress and progress.get("status") == "failed":
@@ -96,7 +104,7 @@ class CalendarGenerationService:
logger.error(f"Traceback: {traceback.format_exc()}")
raise ContentPlanningErrorHandler.handle_general_error(e, "generate_comprehensive_calendar")
async def optimize_content_for_platform(self, user_id: int, title: str, description: str,
async def optimize_content_for_platform(self, user_id: str, title: str, description: str,
content_type: str, target_platform: str, event_id: Optional[int] = None) -> Dict[str, Any]:
"""Optimize content for specific platforms using the 12-step orchestrator."""
try:
@@ -138,7 +146,7 @@ class CalendarGenerationService:
logger.error(f"❌ Error optimizing content: {str(e)}")
raise ContentPlanningErrorHandler.handle_general_error(e, "optimize_content_for_platform")
async def predict_content_performance(self, user_id: int, content_type: str, platform: str,
async def predict_content_performance(self, user_id: str, content_type: str, platform: str,
content_data: Dict[str, Any], strategy_id: Optional[int] = None) -> Dict[str, Any]:
"""Predict content performance using the 12-step orchestrator."""
try:
@@ -172,7 +180,7 @@ class CalendarGenerationService:
logger.error(f"❌ Error predicting content performance: {str(e)}")
raise ContentPlanningErrorHandler.handle_general_error(e, "predict_content_performance")
async def repurpose_content_across_platforms(self, user_id: int, original_content: Dict[str, Any],
async def repurpose_content_across_platforms(self, user_id: str, original_content: Dict[str, Any],
target_platforms: List[str], strategy_id: Optional[int] = None) -> Dict[str, Any]:
"""Repurpose content across different platforms using the 12-step orchestrator."""
try:
@@ -217,7 +225,7 @@ class CalendarGenerationService:
logger.error(f"❌ Error repurposing content: {str(e)}")
raise ContentPlanningErrorHandler.handle_general_error(e, "repurpose_content_across_platforms")
async def get_trending_topics(self, user_id: int, industry: str, limit: int = 10) -> Dict[str, Any]:
async def get_trending_topics(self, user_id: str, industry: str, limit: int = 10) -> Dict[str, Any]:
"""Get trending topics relevant to the user's industry and content gaps using the 12-step orchestrator."""
try:
logger.info(f"📈 Getting trending topics for user {user_id} in {industry} using orchestrator")
@@ -257,7 +265,7 @@ class CalendarGenerationService:
logger.error(f"❌ Error getting trending topics: {str(e)}")
raise ContentPlanningErrorHandler.handle_general_error(e, "get_trending_topics")
async def get_comprehensive_user_data(self, user_id: int) -> Dict[str, Any]:
async def get_comprehensive_user_data(self, user_id: str) -> Dict[str, Any]:
"""Get comprehensive user data for calendar generation using the 12-step orchestrator."""
try:
logger.info(f"Getting comprehensive user data for user_id: {user_id} using orchestrator")
@@ -398,7 +406,7 @@ class CalendarGenerationService:
logger.error(f"❌ Failed to initialize orchestrator session: {e}")
return False
def _cleanup_old_sessions(self, user_id: int) -> None:
def _cleanup_old_sessions(self, user_id: str) -> None:
"""Clean up old sessions for a user."""
try:
current_time = datetime.now()
@@ -426,7 +434,7 @@ class CalendarGenerationService:
except Exception as e:
logger.error(f"❌ Error cleaning up old sessions: {e}")
def _get_active_session_for_user(self, user_id: int) -> Optional[str]:
def _get_active_session_for_user(self, user_id: str) -> Optional[str]:
"""Get active session for a user."""
try:
for session_id, session_data in self.orchestrator_sessions.items():
@@ -540,3 +548,67 @@ class CalendarGenerationService:
except Exception as e:
logger.error(f"❌ Error updating session progress: {e}")
async def _save_calendar_to_db(self, user_id: str, strategy_id: Optional[int], calendar_data: Dict[str, Any], session_id: str) -> None:
"""Save generated calendar to database."""
try:
if not self.db_session:
logger.warning("⚠️ No database session available, skipping persistence")
return
# Save session record
session_record = CalendarGenerationSession(
user_id=user_id,
strategy_id=strategy_id,
session_type=calendar_data.get("calendar_type", "monthly"),
generation_params={"session_id": session_id},
generated_calendar=calendar_data,
ai_insights=calendar_data.get("ai_insights"),
performance_predictions=calendar_data.get("performance_predictions"),
content_themes=calendar_data.get("weekly_themes"),
generation_status="completed",
ai_confidence=calendar_data.get("ai_confidence"),
processing_time=calendar_data.get("processing_time")
)
self.db_session.add(session_record)
self.db_session.flush() # Get ID
# Save calendar events
# Extract daily schedule from calendar data
daily_schedule = calendar_data.get("daily_schedule", [])
# If daily_schedule is not directly available, try to extract from step results
if not daily_schedule and "step_results" in calendar_data:
daily_schedule = calendar_data.get("step_results", {}).get("step_08", {}).get("daily_schedule", [])
for day in daily_schedule:
content_items = day.get("content_items", [])
for item in content_items:
# Parse date
date_str = day.get("date")
scheduled_date = datetime.utcnow()
if date_str:
try:
scheduled_date = datetime.fromisoformat(date_str)
except:
pass
event = CalendarEvent(
strategy_id=strategy_id if strategy_id else 0, # Fallback if no strategy
title=item.get("title", "Untitled Event"),
description=item.get("description"),
content_type=item.get("type", "social_post"),
platform=item.get("platform", "generic"),
scheduled_date=scheduled_date,
status="draft",
ai_recommendations=item
)
self.db_session.add(event)
self.db_session.commit()
logger.info(f"✅ Calendar saved to database for user {user_id}")
except Exception as e:
self.db_session.rollback()
logger.error(f"❌ Error saving calendar to database: {str(e)}")
# Don't raise, just log error so we don't fail the request if persistence fails

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
"""YouTube Creator Studio API endpoints."""

View File

@@ -0,0 +1,877 @@
"""
YouTube Creator Studio API Router
Handles video planning, scene building, and rendering endpoints.
"""
from typing import Any, Dict, List, Optional
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from loguru import logger
from sqlalchemy.orm import Session
from middleware.auth_middleware import get_current_user
from services.database import get_db
from services.youtube.planner import YouTubePlannerService
from services.youtube.scene_builder import YouTubeSceneBuilderService
from services.youtube.renderer import YouTubeVideoRendererService
from services.persona_data_service import PersonaDataService
from services.subscription import PricingService
from services.subscription.preflight_validator import validate_scene_animation_operation
from utils.logger_utils import get_service_logger
from utils.asset_tracker import save_asset_to_library
from .task_manager import task_manager
router = APIRouter(prefix="/youtube", tags=["youtube"])
logger = get_service_logger("api.youtube")
# Video output directory
base_dir = Path(__file__).parent.parent.parent.parent
YOUTUBE_VIDEO_DIR = base_dir / "youtube_videos"
YOUTUBE_VIDEO_DIR.mkdir(parents=True, exist_ok=True)
# Request/Response Models
class VideoPlanRequest(BaseModel):
"""Request model for video planning."""
user_idea: str = Field(..., description="User's video idea or topic")
duration_type: str = Field(
...,
pattern="^(shorts|medium|long)$",
description="Video duration type: shorts (≤60s), medium (1-4min), long (4-10min)"
)
reference_image_description: Optional[str] = Field(
None,
description="Optional description of reference image for visual inspiration"
)
source_content_id: Optional[str] = Field(
None,
description="Optional ID of source content (blog/story) to convert"
)
source_content_type: Optional[str] = Field(
None,
pattern="^(blog|story)$",
description="Type of source content: blog or story"
)
class VideoPlanResponse(BaseModel):
"""Response model for video plan."""
success: bool
plan: Optional[Dict[str, Any]] = None
message: str
class SceneBuildRequest(BaseModel):
"""Request model for scene building."""
video_plan: Dict[str, Any] = Field(..., description="Video plan from planning endpoint")
custom_script: Optional[str] = Field(
None,
description="Optional custom script to use instead of generating from plan"
)
class SceneBuildResponse(BaseModel):
"""Response model for scene building."""
success: bool
scenes: List[Dict[str, Any]] = []
message: str
class SceneUpdateRequest(BaseModel):
"""Request model for updating a single scene."""
scene_id: int = Field(..., description="Scene number to update")
narration: Optional[str] = None
visual_description: Optional[str] = None
duration_estimate: Optional[float] = None
enabled: Optional[bool] = None
class SceneUpdateResponse(BaseModel):
"""Response model for scene update."""
success: bool
scene: Optional[Dict[str, Any]] = None
message: str
class VideoRenderRequest(BaseModel):
"""Request model for video rendering."""
scenes: List[Dict[str, Any]] = Field(..., description="List of scenes to render")
video_plan: Dict[str, Any] = Field(..., description="Original video plan")
resolution: str = Field("720p", pattern="^(480p|720p|1080p)$", description="Video resolution")
combine_scenes: bool = Field(True, description="Whether to combine scenes into single video")
voice_id: str = Field("Wise_Woman", description="Voice ID for narration")
class VideoRenderResponse(BaseModel):
"""Response model for video rendering."""
success: bool
task_id: Optional[str] = None
message: str
class CostEstimateRequest(BaseModel):
"""Request model for cost estimation."""
scenes: List[Dict[str, Any]] = Field(..., description="List of scenes to estimate")
resolution: str = Field("720p", pattern="^(480p|720p|1080p)$", description="Video resolution")
class CostEstimateResponse(BaseModel):
"""Response model for cost estimation."""
success: bool
estimate: Optional[Dict[str, Any]] = None
message: str
# Helper function to get user ID
def require_authenticated_user(current_user: Dict[str, Any]) -> str:
"""Extract and validate user ID from current user."""
user_id = current_user.get("id") if current_user else None
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
return str(user_id)
@router.post("/plan", response_model=VideoPlanResponse)
async def create_video_plan(
request: VideoPlanRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_db),
) -> VideoPlanResponse:
"""
Generate a comprehensive video plan from user input.
This endpoint uses AI to create a detailed plan including:
- Video summary and target audience
- Content outline with timing
- Hook strategy and CTA
- Visual style recommendations
- SEO keywords
"""
try:
user_id = require_authenticated_user(current_user)
logger.info(
f"[YouTubeAPI] Planning video: idea={request.user_idea[:50]}..., "
f"duration={request.duration_type}, user={user_id}"
)
# Get persona data if available
persona_data = None
try:
persona_service = PersonaDataService()
persona_data = persona_service.get_user_persona_data(user_id)
except Exception as e:
logger.warning(f"[YouTubeAPI] Could not load persona data: {e}")
# Generate plan (optimized: for shorts, combine plan + scenes in one call)
planner = YouTubePlannerService()
plan = planner.generate_video_plan(
user_idea=request.user_idea,
duration_type=request.duration_type,
persona_data=persona_data,
reference_image_description=request.reference_image_description,
source_content_id=request.source_content_id,
source_content_type=request.source_content_type,
user_id=user_id,
include_scenes=(request.duration_type == "shorts"), # Optimize shorts
)
return VideoPlanResponse(
success=True,
plan=plan,
message="Video plan generated successfully"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error creating plan: {e}", exc_info=True)
return VideoPlanResponse(
success=False,
message=f"Failed to create video plan: {str(e)}"
)
@router.post("/scenes", response_model=SceneBuildResponse)
async def build_scenes(
request: SceneBuildRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> SceneBuildResponse:
"""
Build structured scenes from a video plan.
Converts the video plan into detailed scenes with:
- Narration text for each scene
- Visual descriptions and prompts
- Timing estimates
- Visual cues and emphasis tags
"""
try:
user_id = require_authenticated_user(current_user)
logger.info(
f"[YouTubeAPI] Building scenes: duration={request.video_plan.get('duration_type')}, "
f"custom_script={bool(request.custom_script)}, user={user_id}"
)
# Build scenes
scene_builder = YouTubeSceneBuilderService()
scenes = scene_builder.build_scenes_from_plan(
video_plan=request.video_plan,
user_id=user_id,
custom_script=request.custom_script,
)
return SceneBuildResponse(
success=True,
scenes=scenes,
message=f"Built {len(scenes)} scenes successfully"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error building scenes: {e}", exc_info=True)
return SceneBuildResponse(
success=False,
message=f"Failed to build scenes: {str(e)}"
)
@router.post("/scenes/{scene_id}/update", response_model=SceneUpdateResponse)
async def update_scene(
scene_id: int,
request: SceneUpdateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> SceneUpdateResponse:
"""
Update a single scene's narration, visual description, or duration.
This allows users to fine-tune individual scenes before rendering.
"""
try:
require_authenticated_user(current_user)
logger.info(f"[YouTubeAPI] Updating scene {scene_id}")
# In a full implementation, this would update a stored scene
# For now, return the updated scene data
updated_scene = {
"scene_number": scene_id,
"narration": request.narration,
"visual_description": request.visual_description,
"duration_estimate": request.duration_estimate,
"enabled": request.enabled if request.enabled is not None else True,
}
return SceneUpdateResponse(
success=True,
scene=updated_scene,
message="Scene updated successfully"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error updating scene: {e}", exc_info=True)
return SceneUpdateResponse(
success=False,
message=f"Failed to update scene: {str(e)}"
)
@router.post("/render", response_model=VideoRenderResponse)
async def start_video_render(
request: VideoRenderRequest,
background_tasks: BackgroundTasks,
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_db),
) -> VideoRenderResponse:
"""
Start rendering a video from scenes asynchronously.
This endpoint creates a background task that:
1. Generates narration audio for each scene
2. Renders each scene using WAN 2.5 text-to-video
3. Combines scenes into final video (if requested)
4. Saves to asset library
Returns task_id for polling progress.
"""
try:
user_id = require_authenticated_user(current_user)
# Validate subscription limits
pricing_service = PricingService(db)
validate_scene_animation_operation(
pricing_service=pricing_service,
user_id=user_id
)
# Filter enabled scenes
enabled_scenes = [s for s in request.scenes if s.get("enabled", True)]
if not enabled_scenes:
return VideoRenderResponse(
success=False,
message="No enabled scenes to render"
)
# VALIDATION: Pre-validate scenes before creating task to prevent wasted API calls
validation_errors = []
for scene in enabled_scenes:
scene_num = scene.get("scene_number", 0)
visual_prompt = (scene.get("enhanced_visual_prompt") or scene.get("visual_prompt", "")).strip()
if not visual_prompt:
validation_errors.append(f"Scene {scene_num}: Missing visual prompt")
elif len(visual_prompt) < 5:
validation_errors.append(f"Scene {scene_num}: Visual prompt too short ({len(visual_prompt)} chars, minimum 5)")
# Validate duration
duration = scene.get("duration_estimate", 5)
if duration < 1 or duration > 10:
validation_errors.append(f"Scene {scene_num}: Invalid duration ({duration}s, must be 1-10 seconds)")
if validation_errors:
error_msg = "Validation failed: " + "; ".join(validation_errors)
logger.warning(f"[YouTubeAPI] {error_msg}")
return VideoRenderResponse(
success=False,
message=error_msg + ". Please fix these issues before rendering."
)
logger.info(
f"[YouTubeAPI] Starting render: {len(enabled_scenes)} scenes, "
f"resolution={request.resolution}, user={user_id}"
)
# Create async task
task_id = task_manager.create_task("youtube_video_render")
logger.info(
f"[YouTubeAPI] Created task {task_id} for user {user_id}, "
f"scenes={len(enabled_scenes)}, resolution={request.resolution}"
)
# Verify task was created
initial_status = task_manager.get_task_status(task_id)
if not initial_status:
logger.error(f"[YouTubeAPI] Failed to create task {task_id} - task not found immediately after creation")
return VideoRenderResponse(
success=False,
message="Failed to create render task. Please try again."
)
# Add background task
try:
background_tasks.add_task(
_execute_video_render_task,
task_id=task_id,
scenes=enabled_scenes,
video_plan=request.video_plan,
user_id=user_id,
resolution=request.resolution,
combine_scenes=request.combine_scenes,
voice_id=request.voice_id,
)
logger.info(f"[YouTubeAPI] Background task added for task {task_id}")
except Exception as bg_error:
logger.error(f"[YouTubeAPI] Failed to add background task for {task_id}: {bg_error}", exc_info=True)
# Mark task as failed
task_manager.update_task_status(
task_id,
"failed",
error=str(bg_error),
message="Failed to start background render task"
)
return VideoRenderResponse(
success=False,
message=f"Failed to start render task: {str(bg_error)}"
)
return VideoRenderResponse(
success=True,
task_id=task_id,
message=f"Video rendering started. Processing {len(enabled_scenes)} scenes..."
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error starting render: {e}", exc_info=True)
return VideoRenderResponse(
success=False,
message=f"Failed to start render: {str(e)}"
)
@router.get("/render/{task_id}")
async def get_render_status(
task_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> Dict[str, Any]:
"""
Get the status of a video rendering task.
Returns current progress, status, and result when complete.
"""
try:
require_authenticated_user(current_user)
logger.debug(f"[YouTubeAPI] Getting render status for task: {task_id}")
task_status = task_manager.get_task_status(task_id)
if not task_status:
logger.warning(
f"[YouTubeAPI] Task {task_id} not found. "
f"Available tasks: {list(task_manager.task_storage.keys())[:5]}..."
)
raise HTTPException(
status_code=404,
detail={
"error": "Task not found",
"message": "The render task was not found. It may have expired, been cleaned up, or the server may have restarted.",
"task_id": task_id,
"user_action": "Please try rendering again."
}
)
return task_status
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error getting render status: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to get render status: {str(e)}"
)
def _execute_video_render_task(
task_id: str,
scenes: List[Dict[str, Any]],
video_plan: Dict[str, Any],
user_id: str,
resolution: str,
combine_scenes: bool,
voice_id: str,
):
"""Background task to render video with progress updates."""
logger.info(
f"[YouTubeRenderer] Background task started for task {task_id}, "
f"scenes={len(scenes)}, user={user_id}"
)
# Verify task exists before starting
task_status = task_manager.get_task_status(task_id)
if not task_status:
logger.error(
f"[YouTubeRenderer] Task {task_id} not found when background task started. "
f"This should not happen - task may have been cleaned up."
)
return
try:
task_manager.update_task_status(
task_id, "processing", progress=5.0, message="Initializing render..."
)
logger.info(f"[YouTubeRenderer] Task {task_id} status updated to processing")
renderer = YouTubeVideoRendererService()
total_scenes = len(scenes)
scene_results = []
total_cost = 0.0
# VALIDATION: Pre-validate all scenes before starting expensive API calls
invalid_scenes = []
for idx, scene in enumerate(scenes):
scene_num = scene.get("scene_number", idx + 1)
visual_prompt = (scene.get("enhanced_visual_prompt") or scene.get("visual_prompt", "")).strip()
if not visual_prompt:
invalid_scenes.append({
"scene_number": scene_num,
"reason": "Missing visual prompt",
"prompt_length": 0
})
elif len(visual_prompt) < 5:
invalid_scenes.append({
"scene_number": scene_num,
"reason": f"Visual prompt too short ({len(visual_prompt)} chars, minimum 5)",
"prompt_length": len(visual_prompt)
})
# Validate duration
duration = scene.get("duration_estimate", 5)
if duration < 1 or duration > 10:
invalid_scenes.append({
"scene_number": scene_num,
"reason": f"Invalid duration ({duration}s, must be 1-10 seconds)",
"prompt_length": len(visual_prompt) if visual_prompt else 0
})
if invalid_scenes:
error_msg = f"Found {len(invalid_scenes)} invalid scene(s) before rendering: " + \
", ".join([f"Scene {s['scene_number']} ({s['reason']})" for s in invalid_scenes])
logger.error(f"[YouTubeRenderer] {error_msg}")
task_manager.update_task_status(
task_id,
"failed",
error=error_msg,
message=f"Validation failed: {len(invalid_scenes)} scene(s) have invalid data. Please fix them before rendering."
)
return
# Render each scene
for idx, scene in enumerate(scenes):
scene_num = scene.get("scene_number", idx + 1)
progress = 5.0 + (idx / total_scenes) * 85.0
task_manager.update_task_status(
task_id,
"processing",
progress=progress,
message=f"Rendering scene {scene_num}/{total_scenes}..."
)
try:
scene_result = renderer.render_scene_video(
scene=scene,
video_plan=video_plan,
user_id=user_id,
resolution=resolution,
generate_audio_enabled=True,
voice_id=voice_id,
)
scene_results.append(scene_result)
total_cost += scene_result["cost"]
# Save to asset library
try:
from services.database import get_db
db = next(get_db())
try:
save_asset_to_library(
db=db,
user_id=user_id,
asset_type="video",
source_module="youtube_creator",
filename=scene_result["video_filename"],
file_url=scene_result["video_url"],
file_path=scene_result["video_path"],
file_size=scene_result["file_size"],
mime_type="video/mp4",
title=f"YouTube Scene {scene_num}: {scene.get('title', 'Untitled')}",
description=f"Scene {scene_num} from YouTube video",
prompt=scene.get("visual_prompt", ""),
tags=["youtube_creator", "video", "scene", f"scene_{scene_num}", resolution],
provider="wavespeed",
model="alibaba/wan-2.5/text-to-video",
cost=scene_result["cost"],
asset_metadata={
"scene_number": scene_num,
"duration": scene_result["duration"],
"resolution": resolution,
"status": "completed"
}
)
finally:
db.close()
except Exception as e:
logger.warning(f"[YouTubeRenderer] Failed to save scene to library: {e}")
except Exception as scene_error:
error_msg = str(scene_error)
scene_error_type = "unknown"
if isinstance(scene_error, HTTPException):
error_detail = scene_error.detail
if isinstance(error_detail, dict):
error_msg = error_detail.get("message", error_detail.get("error", str(error_detail)))
scene_error_type = error_detail.get("error", "http_error")
else:
error_msg = str(error_detail)
# Check if it's a timeout or critical error that should fail fast
if scene_error.status_code == 504: # Timeout
scene_error_type = "timeout"
elif scene_error.status_code >= 500: # Server errors
scene_error_type = "server_error"
else:
# Check error type from exception
if "timeout" in str(scene_error).lower():
scene_error_type = "timeout"
elif "connection" in str(scene_error).lower():
scene_error_type = "connection_error"
logger.error(
f"[YouTubeRenderer] Scene {scene_num} failed: {error_msg} (type: {scene_error_type})",
exc_info=True
)
# Track failed scene for user retry
failed_scene_result = {
"scene_number": scene_num,
"status": "failed",
"error": error_msg,
"error_type": scene_error_type,
"scene_data": scene,
}
scene_results.append(failed_scene_result)
# Update task status immediately to reflect failure
successful_count = len([r for r in scene_results if r.get("status") != "failed"])
failed_count = len([r for r in scene_results if r.get("status") == "failed"])
# Fail fast for critical errors (timeouts, server errors) if it's the first scene
# or if multiple consecutive failures occur
should_fail_fast = (
scene_error_type in ["timeout", "server_error", "connection_error"] and
(failed_count == 1 or failed_count >= 3) # Fail fast on first timeout or 3+ failures
)
if should_fail_fast:
logger.error(
f"[YouTubeRenderer] Failing fast due to {scene_error_type} error. "
f"Scene {scene_num} failed, total failures: {failed_count}"
)
# Mark task as failed immediately
task_manager.update_task_status(
task_id,
"failed",
error=f"Render failed fast: Scene {scene_num} failed with {scene_error_type}",
message=f"Video rendering stopped early due to {scene_error_type}. "
f"{successful_count} scene(s) completed, {failed_count} scene(s) failed. "
f"Failed scene: {error_msg}",
)
# Update result with current state
successful_scenes = [r for r in scene_results if r.get("status") != "failed"]
failed_scenes = [r for r in scene_results if r.get("status") == "failed"]
result = {
"scene_results": successful_scenes,
"failed_scenes": failed_scenes,
"total_cost": total_cost,
"final_video_url": successful_scenes[0]["video_url"] if successful_scenes else None,
"num_scenes": len(successful_scenes),
"num_failed": len(failed_scenes),
"resolution": resolution,
"partial_success": len(failed_scenes) > 0 and len(successful_scenes) > 0,
"fail_fast": True,
"fail_reason": f"Scene {scene_num} failed with {scene_error_type}",
}
task_manager.update_task_status(
task_id,
"failed",
error=f"Render failed fast: {scene_error_type}",
message=f"Rendering stopped early. {successful_count} completed, {failed_count} failed.",
result=result
)
return # Exit immediately
# For non-critical errors, update progress but continue
task_manager.update_task_status(
task_id,
"processing",
progress=progress,
message=f"Scene {scene_num} failed, continuing with remaining scenes... "
f"({successful_count} successful, {failed_count} failed)"
)
# Continue with other scenes - let user retry failed ones
continue
# Separate successful and failed scenes
successful_scenes = [r for r in scene_results if r.get("status") != "failed"]
failed_scenes = [r for r in scene_results if r.get("status") == "failed"]
if not successful_scenes:
# All scenes failed - mark as failed immediately
error_msg = f"All {len(failed_scenes)} scene(s) failed to render"
logger.error(f"[YouTubeRenderer] {error_msg}")
task_manager.update_task_status(
task_id,
"failed",
error=error_msg,
message=f"All scenes failed. First error: {failed_scenes[0].get('error', 'Unknown') if failed_scenes else 'Unknown'}",
result={
"scene_results": [],
"failed_scenes": failed_scenes,
"total_cost": 0.0,
"final_video_url": None,
"num_scenes": 0,
"num_failed": len(failed_scenes),
"resolution": resolution,
"partial_success": False,
}
)
return
# Combine scenes if requested (only if we have successful scenes)
final_video_url = None
if combine_scenes and len(successful_scenes) > 1:
task_manager.update_task_status(
task_id, "processing", progress=90.0, message="Combining scenes..."
)
# Use renderer to combine
combined_result = renderer.render_full_video(
scenes=scenes,
video_plan=video_plan,
user_id=user_id,
resolution=resolution,
combine_scenes=True,
voice_id=voice_id,
)
final_video_url = combined_result.get("final_video_url")
# Final result (successful_scenes and failed_scenes already separated above)
result = {
"scene_results": successful_scenes,
"failed_scenes": failed_scenes,
"total_cost": total_cost,
"final_video_url": final_video_url or (successful_scenes[0]["video_url"] if successful_scenes else None),
"num_successful": len(successful_scenes),
"num_failed": len(failed_scenes),
"resolution": resolution,
"partial_success": len(failed_scenes) > 0 and len(successful_scenes) > 0,
}
# Determine final status based on results
if len(failed_scenes) == 0:
# All scenes succeeded
final_status = "completed"
final_message = f"Video rendering complete! {len(successful_scenes)} scene(s) rendered successfully."
elif len(successful_scenes) > 0:
# Partial success
final_status = "completed" # Still mark as completed but with partial success flag
final_message = f"Video rendering completed with {len(failed_scenes)} failure(s). " \
f"{len(successful_scenes)} scene(s) rendered successfully."
else:
# This shouldn't happen due to early return above, but handle it
final_status = "failed"
final_message = f"All scenes failed to render."
task_manager.update_task_status(
task_id,
final_status,
progress=100.0,
message=final_message,
result=result
)
logger.info(
f"[YouTubeRenderer] ✅ Render task {task_id} completed: "
f"{len(scene_results)} scenes, cost=${total_cost:.2f}"
)
except HTTPException as exc:
error_msg = str(exc.detail) if isinstance(exc.detail, str) else exc.detail.get("error", "Render failed") if isinstance(exc.detail, dict) else "Render failed"
logger.error(f"[YouTubeRenderer] Render task {task_id} failed: {error_msg}")
task_manager.update_task_status(
task_id,
"failed",
error=error_msg,
message=f"Video rendering failed: {error_msg}",
)
except Exception as exc:
error_msg = str(exc)
logger.error(f"[YouTubeRenderer] Render task {task_id} error: {error_msg}", exc_info=True)
task_manager.update_task_status(
task_id,
"failed",
error=error_msg,
message=f"Video rendering error: {error_msg}",
)
@router.post("/estimate-cost", response_model=CostEstimateResponse)
async def estimate_render_cost(
request: CostEstimateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> CostEstimateResponse:
"""
Estimate the cost of rendering a video before actually rendering it.
This endpoint calculates the expected cost based on:
- Number of enabled scenes
- Duration of each scene
- Selected resolution
Returns a detailed cost breakdown.
"""
try:
require_authenticated_user(current_user)
logger.info(
f"[YouTubeAPI] Estimating cost: {len(request.scenes)} scenes, "
f"resolution={request.resolution}"
)
renderer = YouTubeVideoRendererService()
estimate = renderer.estimate_render_cost(
scenes=request.scenes,
resolution=request.resolution,
)
return CostEstimateResponse(
success=True,
estimate=estimate,
message="Cost estimate calculated successfully"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error estimating cost: {e}", exc_info=True)
return CostEstimateResponse(
success=False,
message=f"Failed to estimate cost: {str(e)}"
)
@router.get("/videos/{video_filename}")
async def serve_youtube_video(
video_filename: str,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> FileResponse:
"""
Serve YouTube video files.
This endpoint serves video files generated by the YouTube Creator Studio.
Videos are stored in the youtube_videos directory.
"""
try:
require_authenticated_user(current_user)
# Security: prevent directory traversal
if ".." in video_filename or "/" in video_filename or "\\" in video_filename:
raise HTTPException(status_code=400, detail="Invalid filename")
video_path = YOUTUBE_VIDEO_DIR / video_filename
if not video_path.exists():
raise HTTPException(status_code=404, detail="Video not found")
if not video_path.is_file():
raise HTTPException(status_code=400, detail="Invalid video path")
logger.debug(f"[YouTubeAPI] Serving video: {video_filename}")
return FileResponse(
path=str(video_path),
media_type="video/mp4",
filename=video_filename,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeAPI] Error serving video: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to serve video: {str(e)}"
)

View File

@@ -0,0 +1,11 @@
"""
Task Manager for YouTube Creator Studio
Reuses the Story Writer task manager pattern for async video rendering.
"""
from api.story_writer.task_manager import TaskManager
# Shared task manager instance
task_manager = TaskManager()

View File

@@ -305,6 +305,14 @@ app.include_router(product_marketing_router)
from api.content_assets.router import router as content_assets_router
app.include_router(content_assets_router)
# Include Podcast Maker router
from api.podcast.router import router as podcast_router
app.include_router(podcast_router)
# Include YouTube Creator Studio router
from api.youtube.router import router as youtube_router
app.include_router(youtube_router, prefix="/api")
# Include research configuration router
app.include_router(research_config_router, prefix="/api/research", tags=["research"])

View File

@@ -48,6 +48,9 @@ class AssetSource(enum.Enum):
# Product Marketing Suite
PRODUCT_MARKETING = "product_marketing"
# Podcast Maker
PODCAST_MAKER = "podcast_maker"
class ContentAsset(Base):
"""

View File

@@ -0,0 +1,65 @@
"""
Podcast Maker Models
Database models for podcast project persistence and state management.
"""
from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, JSON, Text, Index
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
# Use the same Base as subscription models for consistency
from models.subscription_models import Base
class PodcastProject(Base):
"""
Database model for podcast project state.
Stores complete project state to enable cross-device resume.
"""
__tablename__ = "podcast_projects"
# Primary fields
id = Column(Integer, primary_key=True, autoincrement=True)
project_id = Column(String(255), unique=True, nullable=False, index=True) # User-facing project ID
user_id = Column(String(255), nullable=False, index=True) # Clerk user ID
# Project metadata
idea = Column(String(1000), nullable=False) # Episode idea or URL
duration = Column(Integer, nullable=False) # Duration in minutes
speakers = Column(Integer, nullable=False, default=1) # Number of speakers
budget_cap = Column(Float, nullable=False, default=50.0) # Budget cap in USD
# Project state (stored as JSON)
# This mirrors the PodcastProjectState interface from frontend
analysis = Column(JSON, nullable=True) # PodcastAnalysis
queries = Column(JSON, nullable=True) # List[Query]
selected_queries = Column(JSON, nullable=True) # Array of query IDs
research = Column(JSON, nullable=True) # Research object
raw_research = Column(JSON, nullable=True) # BlogResearchResponse
estimate = Column(JSON, nullable=True) # PodcastEstimate
script_data = Column(JSON, nullable=True) # Script object
render_jobs = Column(JSON, nullable=True) # List[Job]
knobs = Column(JSON, nullable=True) # Knobs settings
research_provider = Column(String(50), nullable=True, default="google") # Research provider
# UI state
show_script_editor = Column(Boolean, default=False)
show_render_queue = Column(Boolean, default=False)
current_step = Column(String(50), nullable=True) # 'create' | 'analysis' | 'research' | 'script' | 'render'
# Status
status = Column(String(50), default="draft", nullable=False, index=True) # draft, in_progress, completed, archived
is_favorite = Column(Boolean, default=False, index=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, index=True)
# Composite indexes for common query patterns
__table_args__ = (
Index('idx_user_status_created', 'user_id', 'status', 'created_at'),
Index('idx_user_favorite_updated', 'user_id', 'is_favorite', 'updated_at'),
)

View File

@@ -74,8 +74,9 @@ class ProductAsset(Base):
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Additional metadata
metadata = Column(JSON, nullable=True) # Additional product-specific metadata
# Additional metadata (renamed from 'metadata' to avoid SQLAlchemy reserved name conflict)
# Using 'product_metadata' as column name in DB to avoid conflict with SQLAlchemy's reserved 'metadata' attribute
product_metadata = Column('product_metadata', JSON, nullable=True) # Additional product-specific metadata
# Composite indexes
__table_args__ = (

View File

@@ -0,0 +1,149 @@
"""
Database Migration Script for Podcast Maker
Creates the podcast_projects table for cross-device project persistence.
"""
import sys
import os
from pathlib import Path
# Add the backend directory to Python path
backend_dir = Path(__file__).parent.parent
sys.path.insert(0, str(backend_dir))
from sqlalchemy import create_engine, text
from loguru import logger
import traceback
# Import models - PodcastProject uses SubscriptionBase
from models.subscription_models import Base as SubscriptionBase
from models.podcast_models import PodcastProject
from services.database import DATABASE_URL
def create_podcast_tables():
"""Create podcast-related tables."""
try:
# Create engine
engine = create_engine(DATABASE_URL, echo=False)
# Create all tables (PodcastProject uses SubscriptionBase, so it will be created)
logger.info("Creating podcast maker tables...")
SubscriptionBase.metadata.create_all(bind=engine)
logger.info("✅ Podcast tables created successfully")
# Verify table was created
display_setup_summary(engine)
except Exception as e:
logger.error(f"❌ Error creating podcast tables: {e}")
logger.error(traceback.format_exc())
raise
def display_setup_summary(engine):
"""Display a summary of the created tables."""
try:
with engine.connect() as conn:
logger.info("\n" + "="*60)
logger.info("PODCAST MAKER SETUP SUMMARY")
logger.info("="*60)
# Check if table exists
check_query = text("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='podcast_projects'
""")
result = conn.execute(check_query)
table_exists = result.fetchone()
if table_exists:
logger.info("✅ Table 'podcast_projects' created successfully")
# Get table schema
schema_query = text("""
SELECT sql FROM sqlite_master
WHERE type='table' AND name='podcast_projects'
""")
result = conn.execute(schema_query)
schema = result.fetchone()
if schema:
logger.info("\n📋 Table Schema:")
logger.info(schema[0])
# Check indexes
indexes_query = text("""
SELECT name FROM sqlite_master
WHERE type='index' AND tbl_name='podcast_projects'
""")
result = conn.execute(indexes_query)
indexes = result.fetchall()
if indexes:
logger.info(f"\n📊 Indexes ({len(indexes)}):")
for idx in indexes:
logger.info(f"{idx[0]}")
else:
logger.warning("⚠️ Table 'podcast_projects' not found after creation")
logger.info("\n" + "="*60)
logger.info("NEXT STEPS:")
logger.info("="*60)
logger.info("1. The podcast_projects table is ready for use")
logger.info("2. Projects will automatically sync to database after major steps")
logger.info("3. Users can resume projects from any device")
logger.info("4. Use the 'My Projects' button in the Podcast Dashboard to view saved projects")
logger.info("="*60)
except Exception as e:
logger.error(f"Error displaying summary: {e}")
def check_existing_table(engine):
"""Check if podcast_projects table already exists."""
try:
with engine.connect() as conn:
check_query = text("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='podcast_projects'
""")
result = conn.execute(check_query)
table_exists = result.fetchone()
if table_exists:
logger.info(" Table 'podcast_projects' already exists")
logger.info(" Running migration will ensure schema is up to date...")
return True
return False
except Exception as e:
logger.error(f"Error checking existing table: {e}")
return False
if __name__ == "__main__":
logger.info("🚀 Starting podcast maker database migration...")
try:
# Create engine to check existing table
engine = create_engine(DATABASE_URL, echo=False)
# Check existing table
table_exists = check_existing_table(engine)
# Create tables (idempotent - won't recreate if exists)
create_podcast_tables()
logger.info("✅ Migration completed successfully!")
except KeyboardInterrupt:
logger.info("Migration cancelled by user")
sys.exit(0)
except Exception as e:
logger.error(f"❌ Migration failed: {e}")
traceback.print_exc()
sys.exit(1)

View File

@@ -0,0 +1,141 @@
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from loguru import logger
from sqlalchemy import text
from services.database import SessionLocal, engine
# Import models to ensure they are registered and we can recreate them
from models.content_planning import (
ContentStrategy, ContentGapAnalysis, ContentRecommendation, AIAnalysisResult,
Base as ContentPlanningBase
)
from models.enhanced_calendar_models import (
ContentCalendarTemplate, AICalendarRecommendation, ContentPerformanceTracking,
ContentTrendAnalysis, ContentOptimization, CalendarGenerationSession,
Base as EnhancedCalendarBase
)
def migrate_table(db, table_name, base_metadata):
"""Migrate user_id column for a specific table from INTEGER to VARCHAR(255)."""
try:
logger.info(f"Checking table: {table_name}")
# Check if table exists
check_table_query = f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';"
result = db.execute(text(check_table_query))
if not result.scalar():
logger.warning(f"Table '{table_name}' does not exist. Skipping check, but will try to create it.")
# If it doesn't exist, we can just create it with the new schema
try:
base_metadata.create_all(bind=engine, tables=[base_metadata.tables[table_name]], checkfirst=True)
logger.success(f"✅ Created {table_name} with new schema")
except Exception as e:
logger.error(f"Failed to create {table_name}: {e}")
return True
# Check current column type
check_column_query = f"SELECT type FROM pragma_table_info('{table_name}') WHERE name = 'user_id';"
result = db.execute(text(check_column_query))
current_type = result.scalar()
if not current_type:
logger.info(f"Table {table_name} does not have user_id column. Skipping.")
return True
if 'varchar' in current_type.lower() or 'text' in current_type.lower():
logger.info(f"{table_name}.user_id is already {current_type}. No migration needed.")
return True
logger.info(f"Migrating {table_name}.user_id from {current_type} to VARCHAR...")
# Backup data
backup_table = f"{table_name}_backup"
db.execute(text(f"DROP TABLE IF EXISTS {backup_table}")) # Ensure clean state
db.execute(text(f"CREATE TABLE {backup_table} AS SELECT * FROM {table_name}"))
# Drop old table
db.execute(text(f"DROP TABLE {table_name}"))
# Recreate table
# We need to find the Table object in metadata
table_obj = base_metadata.tables.get(table_name)
if table_obj is not None:
base_metadata.create_all(bind=engine, tables=[table_obj], checkfirst=False)
else:
logger.error(f"Could not find Table object for {table_name} in metadata")
# Restore backup and abort
db.execute(text(f"ALTER TABLE {backup_table} RENAME TO {table_name}"))
return False
# Restore data
# We need to list columns to construct INSERT statement, excluding those that might be auto-generated if needed,
# but usually for restore we want all.
# However, we need to cast user_id to TEXT.
# Get columns from backup
columns_result = db.execute(text(f"PRAGMA table_info({backup_table})"))
columns = [row[1] for row in columns_result]
cols_str = ", ".join(columns)
# Construct select list with cast
select_parts = []
for col in columns:
if col == 'user_id':
select_parts.append("CAST(user_id AS TEXT)")
else:
select_parts.append(col)
select_str = ", ".join(select_parts)
restore_query = f"INSERT INTO {table_name} ({cols_str}) SELECT {select_str} FROM {backup_table}"
db.execute(text(restore_query))
# Drop backup
db.execute(text(f"DROP TABLE {backup_table}"))
db.commit()
logger.success(f"✅ Migrated {table_name} successfully")
return True
except Exception as e:
logger.error(f"❌ Failed to migrate {table_name}: {e}")
db.rollback()
return False
def migrate_all():
db = SessionLocal()
try:
# Content Planning Tables
cp_tables = [
"content_strategies",
"content_gap_analyses",
"content_recommendations",
"ai_analysis_results"
]
for table in cp_tables:
migrate_table(db, table, ContentPlanningBase.metadata)
# Enhanced Calendar Tables
ec_tables = [
"content_calendar_templates",
"ai_calendar_recommendations",
"content_performance_tracking",
"content_trend_analysis",
"content_optimizations",
"calendar_generation_sessions"
]
for table in ec_tables:
migrate_table(db, table, EnhancedCalendarBase.metadata)
finally:
db.close()
if __name__ == "__main__":
logger.info("Starting comprehensive user_id migration...")
migrate_all()
logger.info("Migration finished.")

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""
Verify that the podcast_projects table exists and has the correct structure.
"""
import sys
from pathlib import Path
backend_dir = Path(__file__).parent.parent
sys.path.insert(0, str(backend_dir))
from sqlalchemy import inspect
from services.database import engine
def verify_table():
"""Verify the podcast_projects table exists."""
inspector = inspect(engine)
tables = inspector.get_table_names()
if 'podcast_projects' in tables:
print("✅ Table 'podcast_projects' exists")
columns = inspector.get_columns('podcast_projects')
print(f"\n📊 Columns ({len(columns)}):")
for col in columns:
print(f"{col['name']}: {col['type']}")
indexes = inspector.get_indexes('podcast_projects')
print(f"\n📈 Indexes ({len(indexes)}):")
for idx in indexes:
print(f"{idx['name']}: {idx['column_names']}")
return True
else:
print("❌ Table 'podcast_projects' not found")
print(f"Available tables: {', '.join(tables)}")
return False
if __name__ == "__main__":
success = verify_table()
sys.exit(0 if success else 1)

View File

@@ -29,17 +29,15 @@ class ExaResearchProvider(BaseProvider):
# Determine category: use exa_category if set, otherwise map from source_types
category = config.exa_category if config.exa_category else self._map_source_type_to_category(config.source_types)
# Build search kwargs
# Build search kwargs - use correct Exa API format
search_kwargs = {
'type': config.exa_search_type or "auto",
'num_results': min(config.max_sources, 25),
'contents': {
'text': {'max_characters': 1000},
'summary': {'query': f"Key insights about {topic}"},
'highlights': {
'num_sentences': 2,
'highlights_per_url': 3
}
'text': {'max_characters': 1000},
'summary': {'query': f"Key insights about {topic}"},
'highlights': {
'num_sentences': 2,
'highlights_per_url': 3
}
}
@@ -53,8 +51,39 @@ class ExaResearchProvider(BaseProvider):
logger.info(f"[Exa Research] Executing search: {query}")
# Execute Exa search
results = self.exa.search_and_contents(query, **search_kwargs)
# Execute Exa search - pass contents parameters directly, not nested
try:
results = self.exa.search_and_contents(
query,
text={'max_characters': 1000},
summary={'query': f"Key insights about {topic}"},
highlights={'num_sentences': 2, 'highlights_per_url': 3},
type=config.exa_search_type or "auto",
num_results=min(config.max_sources, 25),
**({k: v for k, v in {
'category': category,
'include_domains': config.exa_include_domains,
'exclude_domains': config.exa_exclude_domains
}.items() if v})
)
except Exception as e:
logger.error(f"[Exa Research] API call failed: {e}")
# Try simpler call without contents if the above fails
try:
logger.info("[Exa Research] Retrying with simplified parameters")
results = self.exa.search_and_contents(
query,
type=config.exa_search_type or "auto",
num_results=min(config.max_sources, 25),
**({k: v for k, v in {
'category': category,
'include_domains': config.exa_include_domains,
'exclude_domains': config.exa_exclude_domains
}.items() if v})
)
except Exception as retry_error:
logger.error(f"[Exa Research] Retry also failed: {retry_error}")
raise RuntimeError(f"Exa search failed: {str(retry_error)}") from retry_error
# Transform to standardized format
sources = self._transform_sources(results.results)

View File

@@ -52,45 +52,44 @@ class BasicResearchStrategy(ResearchStrategy):
target_audience: str,
config: ResearchConfig
) -> str:
"""Build basic research prompt focused on keywords and quick insights."""
prompt = f"""You are a professional blog content strategist researching for a {industry} blog targeting {target_audience}.
"""Build basic research prompt focused on podcast-ready, actionable insights."""
prompt = f"""You are a podcast researcher creating TALKING POINTS and FACT CARDS for a {industry} audience of {target_audience}.
Research Topic: "{topic}"
Provide analysis in this EXACT format:
## CURRENT TRENDS (2024-2025)
- [Trend 1 with specific data and source URL]
- [Trend 2 with specific data and source URL]
- [Trend 3 with specific data and source URL]
## PODCAST HOOKS (3)
- [Hook line with tension + data point + source URL]
## KEY STATISTICS
- [Statistic 1: specific number/percentage with source URL]
- [Statistic 2: specific number/percentage with source URL]
- [Statistic 3: specific number/percentage with source URL]
- [Statistic 4: specific number/percentage with source URL]
- [Statistic 5: specific number/percentage with source URL]
## OBJECTIONS & COUNTERS (3)
- Objection: [common listener objection]
Counter: [concise rebuttal with stat + source URL]
## PRIMARY KEYWORDS
1. "{topic}" (main keyword)
2. [Variation 1]
3. [Variation 2]
## KEY STATS & PROOF (6)
- [Specific metric with %/number, date, and source URL]
## SECONDARY KEYWORDS
[5 related keywords for blog content]
## MINI CASE SNAPS (3)
- [Brand/company], [what they did], [outcome metric], [source URL]
## CONTENT ANGLES (Top 5)
1. [Angle 1: specific unique approach]
2. [Angle 2: specific unique approach]
3. [Angle 3: specific unique approach]
4. [Angle 4: specific unique approach]
5. [Angle 5: specific unique approach]
## KEYWORDS TO MENTION (Primary + 5 Secondary)
- Primary: "{topic}"
- Secondary: [5 related keywords]
## 5 CONTENT ANGLES
1. [Angle with audience benefit + why-now]
2. [Angle ...]
3. [Angle ...]
4. [Angle ...]
5. [Angle ...]
## FACT CARD LIST (8)
- For each: Quote/claim, source URL, published date, metric/context.
REQUIREMENTS:
- Cite EVERY claim with authoritative source URLs
- Use 2024-2025 data when available
- Include specific numbers, dates, examples
- Focus on actionable blog insights for {target_audience}"""
- Every claim MUST include a source URL (authoritative, recent: 2024-2025 preferred).
- Use concrete numbers, dates, outcomes; avoid generic advice.
- Keep bullets tight and scannable for spoken narration."""
return prompt.strip()
@@ -107,57 +106,54 @@ class ComprehensiveResearchStrategy(ResearchStrategy):
target_audience: str,
config: ResearchConfig
) -> str:
"""Build comprehensive research prompt with all analysis components."""
"""Build comprehensive research prompt with podcast-focused, high-value insights."""
date_filter = f"\nDate Focus: {config.date_range.value.replace('_', ' ')}" if config.date_range else ""
source_filter = f"\nPriority Sources: {', '.join([s.value for s in config.source_types])}" if config.source_types else ""
prompt = f"""You are a senior blog content strategist conducting comprehensive research for a {industry} blog targeting {target_audience}.
prompt = f"""You are a senior podcast researcher creating deeply sourced talking points for a {industry} audience of {target_audience}.
Research Topic: "{topic}"{date_filter}{source_filter}
Provide COMPLETE analysis in this EXACT format:
## TRENDS AND INSIGHTS (2024-2025)
[5-7 trends with specific data, numbers, and source URLs]
## WHAT'S CHANGED (2024-2025)
[5-7 concise trend bullets with numbers + source URLs]
## KEY STATISTICS
[7-10 statistics with exact numbers, percentages, dates, and source URLs]
## PROOF & NUMBERS
[10 stats with metric, date, sample size/method, and source URL]
## EXPERT OPINIONS
[4-5 expert quotes with full attribution and source URLs]
## EXPERT SIGNALS
[5 expert quotes with name, title/company, source URL]
## RECENT DEVELOPMENTS
[5-7 recent news/developments with dates and source URLs]
## RECENT MOVES
[5-7 news items or launches with dates and source URLs]
## MARKET ANALYSIS
[3-5 market insights with data points and source URLs]
## MARKET SNAPSHOTS
[3-5 insights with TAM/SAM/SOM or adoption metrics, source URLs]
## BEST PRACTICES & CASE STUDIES
[3-5 examples with specific outcomes/metrics and source URLs]
## CASE SNAPS
[3-5 cases: who, what they did, outcome metric, source URL]
## KEYWORD ANALYSIS
Primary Keywords: [3 main variations]
Secondary Keywords: [7-10 related keywords]
Long-Tail Opportunities: [5-7 specific search phrases]
## KEYWORD PLAN
Primary (3), Secondary (8-10), Long-tail (5-7) with intent hints.
## COMPETITOR ANALYSIS
Top Competitors: [5 competitors with brief descriptions]
Content Gaps: [5 topics competitors are missing]
Competitive Advantages: [5 unique angles we can own]
## COMPETITOR GAPS
- Top 5 competitors (URL) + 1-line strength
- 5 content gaps we can own
- 3 unique angles to differentiate
## CONTENT ANGLES (Exactly 5)
1. [Unique angle with reasoning and target benefit]
2. [Unique angle with reasoning and target benefit]
3. [Unique angle with reasoning and target benefit]
4. [Unique angle with reasoning and target benefit]
5. [Unique angle with reasoning and target benefit]
## PODCAST-READY ANGLES (5)
- Each: Hook, promised takeaway, data or example, source URL.
## FACT CARD LIST (10)
- Each: Quote/claim, source URL, published date, metric/context, suggested angle tag.
VERIFICATION REQUIREMENTS:
- Minimum 2 authoritative sources per major claim
- Prioritize: Industry publications > Research papers > News > Blogs
- 2024-2025 data strongly preferred
- All numbers must include context (timeframe, sample size, methodology)
- Every recommendation must be actionable for {target_audience}"""
- Minimum 2 authoritative sources per major claim.
- Prefer industry reports > research papers > news > blogs.
- 2024-2025 data strongly preferred.
- All numbers must include timeframe and methodology.
- Every bullet must be concise for spoken narration and actionable for {target_audience}."""
return prompt.strip()

View File

@@ -78,6 +78,23 @@ class DailyScheduleGenerator:
try:
logger.info("🚀 Starting daily schedule generation")
# CRITICAL VALIDATION: Ensure weekly_themes is a list of dictionaries
if not isinstance(weekly_themes, list):
raise TypeError(f"weekly_themes must be a list, got {type(weekly_themes)}")
if not weekly_themes:
raise ValueError("weekly_themes cannot be empty")
for i, theme in enumerate(weekly_themes):
if not isinstance(theme, dict):
raise TypeError(f"weekly_themes[{i}] must be a dictionary, got {type(theme)}. Value: {theme}")
# Validate required fields
if "week_number" not in theme:
raise ValueError(f"weekly_themes[{i}] missing required 'week_number' field")
logger.info(f"✅ Validated {len(weekly_themes)} weekly themes")
daily_schedules = []
current_date = datetime.now()
@@ -153,12 +170,22 @@ class DailyScheduleGenerator:
def _get_weekly_theme(self, weekly_themes: List[Dict], week_number: int) -> Dict:
"""Get weekly theme for specific week number."""
try:
# Additional validation
if not isinstance(weekly_themes, list):
raise TypeError(f"weekly_themes must be a list, got {type(weekly_themes)}")
for theme in weekly_themes:
if not isinstance(theme, dict):
raise TypeError(f"Theme must be a dictionary, got {type(theme)}: {theme}")
if theme.get("week_number") == week_number:
return theme
# If no theme found, fail with clear error
raise ValueError(f"No weekly theme found for week {week_number}")
raise ValueError(
f"No weekly theme found for week {week_number}. "
f"Available weeks: {[t.get('week_number') for t in weekly_themes if isinstance(t, dict)]}"
)
except Exception as e:
logger.error(f"Error getting weekly theme: {str(e)}")
@@ -205,9 +232,21 @@ class DailyScheduleGenerator:
# Call AI service - NO FALLBACKS
ai_response = await self.ai_engine.generate_content_recommendations(analysis_data)
# Validate AI response - NO FALLBACKS
# ENHANCED VALIDATION: Check for unexpected types (including float)
if ai_response is None:
raise ValueError("AI service returned None")
if isinstance(ai_response, (int, float, str, bool)):
raise TypeError(
f"AI service returned primitive type {type(ai_response).__name__}: {ai_response}. "
f"Expected list of dictionaries. This indicates an AI service error."
)
if not isinstance(ai_response, list):
raise ValueError(f"AI service returned unexpected type: {type(ai_response)}. Expected list, got {type(ai_response)}")
raise TypeError(
f"AI service returned unexpected type: {type(ai_response).__name__}. "
f"Expected list, got {type(ai_response)}. Value: {str(ai_response)[:200]}"
)
if not ai_response:
raise ValueError("AI service returned empty list of recommendations")

View File

@@ -25,6 +25,8 @@ from models.content_asset_models import Base as ContentAssetBase
from models.product_marketing_models import Campaign, CampaignProposal, CampaignAsset
# Product Asset models (Product Marketing Suite - product assets, not campaigns)
from models.product_asset_models import ProductAsset, ProductStyleTemplate, EcommerceExport
# Podcast Maker models use SubscriptionBase, but import to ensure models are registered
from models.podcast_models import PodcastProject
# Database configuration
DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite:///./alwrity.db')

View File

@@ -69,13 +69,21 @@ def generate_audio(
RuntimeError: If subscription limits are exceeded or user_id is missing.
"""
try:
logger.info("[audio_gen] Starting audio generation")
logger.debug(f"[audio_gen] Text length: {len(text)} characters, voice: {voice_id}")
# VALIDATION: Check inputs before any processing or API calls
if not text or not isinstance(text, str) or len(text.strip()) == 0:
raise ValueError("Text input is required and cannot be empty")
text = text.strip() # Normalize whitespace
if len(text) > 10000:
raise ValueError(f"Text is too long ({len(text)} characters). Maximum is 10,000 characters.")
# SUBSCRIPTION CHECK - Required and strict enforcement
if not user_id:
raise RuntimeError("user_id is required for subscription checking. Please provide Clerk user ID.")
logger.info("[audio_gen] Starting audio generation")
logger.debug(f"[audio_gen] Text length: {len(text)} characters, voice: {voice_id}")
# Calculate cost based on character count (every character is 1 token)
# Pricing: $0.05 per 1,000 characters
character_count = len(text)
@@ -190,8 +198,9 @@ def generate_audio(
new_cost = current_cost_before + estimated_cost
# Use direct SQL UPDATE for dynamic attributes
from sqlalchemy import text
update_query = text("""
# Import sqlalchemy.text with alias to avoid shadowing the 'text' parameter
from sqlalchemy import text as sql_text
update_query = sql_text("""
UPDATE usage_summaries
SET audio_calls = :new_calls,
audio_cost = :new_cost
@@ -210,6 +219,8 @@ def generate_audio(
summary.updated_at = datetime.utcnow()
# Create usage log
# Store the text parameter in a local variable before any imports to prevent shadowing
text_param = text # Capture function parameter before any potential shadowing
usage_log = APIUsageLog(
user_id=user_id,
provider=APIProvider.AUDIO,
@@ -224,7 +235,7 @@ def generate_audio(
cost_total=estimated_cost,
response_time=0.0,
status_code=200,
request_size=len(text.encode("utf-8")),
request_size=len(text_param.encode("utf-8")), # Use captured parameter
response_size=len(audio_bytes),
billing_period=current_period,
)

View File

@@ -0,0 +1,139 @@
"""
Podcast Service
Service layer for managing podcast project persistence.
"""
from sqlalchemy.orm import Session
from sqlalchemy import desc, and_, or_
from typing import Optional, List, Dict, Any
from datetime import datetime
import uuid
from models.podcast_models import PodcastProject
class PodcastService:
"""Service for managing podcast projects."""
def __init__(self, db: Session):
self.db = db
def create_project(
self,
user_id: str,
project_id: str,
idea: str,
duration: int,
speakers: int,
budget_cap: float,
**kwargs
) -> PodcastProject:
"""Create a new podcast project."""
project = PodcastProject(
project_id=project_id,
user_id=user_id,
idea=idea,
duration=duration,
speakers=speakers,
budget_cap=budget_cap,
status="draft",
current_step="create",
**kwargs
)
self.db.add(project)
self.db.commit()
self.db.refresh(project)
return project
def get_project(self, user_id: str, project_id: str) -> Optional[PodcastProject]:
"""Get a project by ID, ensuring user ownership."""
return self.db.query(PodcastProject).filter(
and_(
PodcastProject.project_id == project_id,
PodcastProject.user_id == user_id
)
).first()
def update_project(
self,
user_id: str,
project_id: str,
**updates
) -> Optional[PodcastProject]:
"""Update project fields."""
project = self.get_project(user_id, project_id)
if not project:
return None
# Update fields
for key, value in updates.items():
if hasattr(project, key):
setattr(project, key, value)
project.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(project)
return project
def list_projects(
self,
user_id: str,
status: Optional[str] = None,
favorites_only: bool = False,
limit: int = 50,
offset: int = 0,
order_by: str = "updated_at" # "updated_at" or "created_at"
) -> tuple[List[PodcastProject], int]:
"""List user's projects with optional filtering."""
query = self.db.query(PodcastProject).filter(
PodcastProject.user_id == user_id
)
# Apply filters
if status:
query = query.filter(PodcastProject.status == status)
if favorites_only:
query = query.filter(PodcastProject.is_favorite == True)
# Get total count before pagination
total = query.count()
# Apply ordering
if order_by == "created_at":
query = query.order_by(desc(PodcastProject.created_at))
else:
query = query.order_by(desc(PodcastProject.updated_at))
# Apply pagination
projects = query.offset(offset).limit(limit).all()
return projects, total
def delete_project(self, user_id: str, project_id: str) -> bool:
"""Delete a project."""
project = self.get_project(user_id, project_id)
if not project:
return False
self.db.delete(project)
self.db.commit()
return True
def toggle_favorite(self, user_id: str, project_id: str) -> Optional[PodcastProject]:
"""Toggle favorite status of a project."""
project = self.get_project(user_id, project_id)
if not project:
return None
project.is_favorite = not project.is_favorite
project.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(project)
return project
def update_status(self, user_id: str, project_id: str, status: str) -> Optional[PodcastProject]:
"""Update project status."""
return self.update_project(user_id, project_id, status=status)

View File

@@ -8,6 +8,8 @@ from typing import Any, Dict, List
from fastapi import HTTPException
from loguru import logger
from services.llm_providers.main_text_generation import llm_text_gen
from .base import StoryServiceBase

View File

@@ -545,6 +545,188 @@ def validate_video_generation_operations(
)
def validate_scene_animation_operation(
pricing_service: PricingService,
user_id: str,
) -> None:
"""
Validate the per-scene animation workflow before API calls.
"""
try:
operations_to_validate = [
{
'provider': APIProvider.VIDEO,
'tokens_requested': 0,
'actual_provider_name': 'wavespeed',
'operation_type': 'scene_animation',
}
]
can_proceed, message, error_details = pricing_service.check_comprehensive_limits(
user_id=user_id,
operations=operations_to_validate,
)
if not can_proceed:
logger.error(f"[Pre-flight Validator] Scene animation blocked for user {user_id}: {message}")
usage_info = error_details.get('usage_info', {}) if error_details else {}
provider = usage_info.get('provider', 'video') if usage_info else 'video'
raise HTTPException(
status_code=429,
detail={
'error': message,
'message': message,
'provider': provider,
'usage_info': usage_info if usage_info else error_details,
}
)
logger.info(f"[Pre-flight Validator] ✅ Scene animation validated for user {user_id}")
# Validation passed - no return needed (function raises HTTPException if validation fails)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Pre-flight Validator] Error validating scene animation: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={
'error': f"Failed to validate scene animation: {str(e)}",
'message': f"Failed to validate scene animation: {str(e)}"
}
)
def validate_image_control_operations(
pricing_service: PricingService,
user_id: str,
num_images: int = 1
) -> None:
"""
Validate image control operations (sketch-to-image, structure control, style transfer) before making API calls.
Control operations use Stability AI for image generation with control inputs, so they use
the same validation as image generation operations.
Args:
pricing_service: PricingService instance
user_id: User ID for subscription checking
num_images: Number of images to generate (for multiple variations)
Returns:
None - raises HTTPException with 429 status if validation fails
"""
try:
# Control operations use Stability AI, same as image generation
operations_to_validate = [
{
'provider': APIProvider.STABILITY,
'tokens_requested': 0,
'actual_provider_name': 'stability',
'operation_type': 'image_generation' # Control ops use image generation limits
}
for _ in range(num_images)
]
logger.info(f"[Pre-flight Validator] 🚀 Validating {num_images} image control operation(s) for user {user_id}")
can_proceed, message, error_details = pricing_service.check_comprehensive_limits(
user_id=user_id,
operations=operations_to_validate
)
if not can_proceed:
logger.error(f"[Pre-flight Validator] Image control blocked for user {user_id}: {message}")
usage_info = error_details.get('usage_info', {}) if error_details else {}
provider = usage_info.get('provider', 'stability') if usage_info else 'stability'
raise HTTPException(
status_code=429,
detail={
'error': message,
'message': message,
'provider': provider,
'usage_info': usage_info if usage_info else error_details
}
)
logger.info(f"[Pre-flight Validator] ✅ Image control validated for user {user_id}")
except HTTPException:
raise
except Exception as e:
logger.error(f"[Pre-flight Validator] Error validating image control: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={
'error': f"Failed to validate image control: {str(e)}",
'message': f"Failed to validate image control: {str(e)}"
}
)
def validate_video_generation_operations(
pricing_service: PricingService,
user_id: str
) -> None:
"""
Validate video generation operation before making API calls.
Args:
pricing_service: PricingService instance
user_id: User ID for subscription checking
Returns:
None - raises HTTPException with 429 status if validation fails
"""
try:
operations_to_validate = [
{
'provider': APIProvider.VIDEO,
'tokens_requested': 0,
'actual_provider_name': 'video',
'operation_type': 'video_generation'
}
]
can_proceed, message, error_details = pricing_service.check_comprehensive_limits(
user_id=user_id,
operations=operations_to_validate
)
if not can_proceed:
logger.error(f"[Pre-flight Validator] Video generation blocked for user {user_id}: {message}")
usage_info = error_details.get('usage_info', {}) if error_details else {}
provider = usage_info.get('provider', 'video') if usage_info else 'video'
raise HTTPException(
status_code=429,
detail={
'error': message,
'message': message,
'provider': provider,
'usage_info': usage_info if usage_info else error_details
}
)
logger.info(f"[Pre-flight Validator] ✅ Video generation validated for user {user_id}")
# Validation passed - no return needed (function raises HTTPException if validation fails)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Pre-flight Validator] Error validating video generation: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={
'error': f"Failed to validate video generation: {str(e)}",
'message': f"Failed to validate video generation: {str(e)}"
}
)
def validate_scene_animation_operation(
pricing_service: PricingService,
user_id: str,
@@ -593,4 +775,79 @@ def validate_scene_animation_operation(
'error': f"Failed to validate scene animation: {str(e)}",
'message': f"Failed to validate scene animation: {str(e)}",
},
)
def validate_calendar_generation_operations(
pricing_service: PricingService,
user_id: str,
gpt_provider: str = "google"
) -> None:
"""
Validate calendar generation operations before making API calls.
Args:
pricing_service: PricingService instance
user_id: User ID for subscription checking
gpt_provider: GPT provider from env var (defaults to "google")
Returns:
None - raises HTTPException with 429 status if validation fails
"""
try:
# Determine actual provider for LLM calls based on GPT_PROVIDER env var
gpt_provider_lower = gpt_provider.lower()
if gpt_provider_lower == "huggingface":
llm_provider_enum = APIProvider.MISTRAL
llm_provider_name = "huggingface"
else:
llm_provider_enum = APIProvider.GEMINI
llm_provider_name = "gemini"
# Estimate tokens for 12-step process
# This is a heavy operation involving multiple steps and analysis
operations_to_validate = [
{
'provider': llm_provider_enum,
'tokens_requested': 20000, # Conservative estimate for full calendar generation
'actual_provider_name': llm_provider_name,
'operation_type': 'calendar_generation'
}
]
logger.info(f"[Pre-flight Validator] 🚀 Validating Calendar Generation for user {user_id}")
can_proceed, message, error_details = pricing_service.check_comprehensive_limits(
user_id=user_id,
operations=operations_to_validate
)
if not can_proceed:
usage_info = error_details.get('usage_info', {}) if error_details else {}
provider = usage_info.get('provider', llm_provider_name) if usage_info else llm_provider_name
logger.warning(f"[Pre-flight Validator] Calendar generation blocked for user {user_id}: {message}")
raise HTTPException(
status_code=429,
detail={
'error': message,
'message': message,
'provider': provider,
'usage_info': usage_info if usage_info else error_details
}
)
logger.info(f"[Pre-flight Validator] ✅ Calendar Generation validated for user {user_id}")
except HTTPException:
raise
except Exception as e:
logger.error(f"[Pre-flight Validator] Error validating calendar generation: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={
'error': f"Failed to validate calendar generation: {str(e)}",
'message': f"Failed to validate calendar generation: {str(e)}"
}
)

View File

@@ -637,4 +637,260 @@ class WaveSpeedClient:
status_code=502,
detail="Failed to fetch generated audio from WaveSpeed URL",
)
def submit_text_to_video(
self,
model_path: str,
payload: Dict[str, Any],
timeout: int = 60,
) -> str:
"""
Submit a text-to-video generation request to WaveSpeed.
Args:
model_path: Model path (e.g., "alibaba/wan-2.5/text-to-video")
payload: Request payload with prompt, resolution, duration, optional audio
timeout: Request timeout in seconds
Returns:
Prediction ID for polling
"""
url = f"{self.BASE_URL}/{model_path}"
logger.info(f"[WaveSpeed] Submitting text-to-video request to {url}")
response = requests.post(url, headers=self._headers(), json=payload, timeout=timeout)
if response.status_code != 200:
logger.error(f"[WaveSpeed] Text-to-video submission failed: {response.status_code} {response.text}")
raise HTTPException(
status_code=502,
detail={
"error": "WaveSpeed text-to-video submission failed",
"status_code": response.status_code,
"response": response.text,
},
)
data = response.json().get("data")
if not data or "id" not in data:
logger.error(f"[WaveSpeed] Unexpected text-to-video response: {response.text}")
raise HTTPException(
status_code=502,
detail={"error": "WaveSpeed response missing prediction id"},
)
prediction_id = data["id"]
logger.info(f"[WaveSpeed] Submitted text-to-video request: {prediction_id}")
return prediction_id
def generate_text_video(
self,
prompt: str,
resolution: str = "720p", # 480p, 720p, 1080p
duration: int = 5, # 5 or 10 seconds
audio_base64: Optional[str] = None, # Optional audio for lip-sync
negative_prompt: Optional[str] = None,
seed: Optional[int] = None,
enable_prompt_expansion: bool = True,
enable_sync_mode: bool = False,
timeout: int = 180,
) -> Dict[str, Any]:
"""
Generate video from text prompt using WAN 2.5 text-to-video.
Args:
prompt: Text prompt describing the video
resolution: Output resolution (480p, 720p, 1080p)
duration: Video duration in seconds (5 or 10)
audio_base64: Optional audio file (wav/mp3, 3-30s, ≤15MB) for lip-sync
negative_prompt: Optional negative prompt
seed: Optional random seed for reproducibility
enable_prompt_expansion: Enable prompt optimizer
enable_sync_mode: If True, wait for result and return it directly
timeout: Request timeout in seconds
Returns:
Dictionary with video bytes, metadata, and cost
"""
model_path = "alibaba/wan-2.5/text-to-video"
# Validate resolution
valid_resolutions = ["480p", "720p", "1080p"]
if resolution not in valid_resolutions:
raise HTTPException(
status_code=400,
detail=f"Invalid resolution: {resolution}. Must be one of: {valid_resolutions}"
)
# Validate duration
if duration not in [5, 10]:
raise HTTPException(
status_code=400,
detail="Duration must be 5 or 10 seconds"
)
# Build payload
payload = {
"prompt": prompt,
"resolution": resolution,
"duration": duration,
"enable_prompt_expansion": enable_prompt_expansion,
"enable_sync_mode": enable_sync_mode, # Add sync mode to payload
}
# Add optional audio
if audio_base64:
payload["audio"] = audio_base64
# Add optional parameters
if negative_prompt:
payload["negative_prompt"] = negative_prompt
if seed is not None:
payload["seed"] = seed
# Submit request
logger.info(
f"[WaveSpeed] Generating text-to-video: resolution={resolution}, "
f"duration={duration}s, prompt_length={len(prompt)}, sync_mode={enable_sync_mode}"
)
# For sync mode, submit and get result directly
if enable_sync_mode:
url = f"{self.BASE_URL}/{model_path}"
response = requests.post(url, headers=self._headers(), json=payload, timeout=timeout)
if response.status_code != 200:
logger.error(f"[WaveSpeed] Text-to-video submission failed: {response.status_code} {response.text}")
raise HTTPException(
status_code=502,
detail={
"error": "WaveSpeed text-to-video submission failed",
"status_code": response.status_code,
"response": response.text[:500],
},
)
response_json = response.json()
data = response_json.get("data") or response_json
# In sync mode, result should be directly in outputs
outputs = data.get("outputs") or []
if not outputs:
logger.error(f"[WaveSpeed] No outputs in sync mode response: {response.text[:500]}")
raise HTTPException(
status_code=502,
detail="WaveSpeed text-to-video returned no outputs in sync mode",
)
# Extract video URL from outputs
video_url = outputs[0]
if not isinstance(video_url, str) or not video_url.startswith("http"):
logger.error(f"[WaveSpeed] Invalid video URL format in sync mode: {video_url}")
raise HTTPException(
status_code=502,
detail=f"Invalid video URL format: {video_url}",
)
# Download video
logger.info(f"[WaveSpeed] Downloading video from sync mode URL: {video_url}")
video_response = requests.get(video_url, timeout=180)
if video_response.status_code != 200:
raise HTTPException(
status_code=502,
detail={
"error": "Failed to download WAN 2.5 video from sync mode",
"status_code": video_response.status_code,
"response": video_response.text[:200],
}
)
video_bytes = video_response.content
prediction_id = data.get("id", "sync_mode")
metadata = data.get("metadata") or {}
# video_url is already set above for sync mode
else:
# Async mode - submit and poll
prediction_id = self.submit_text_to_video(model_path, payload, timeout=timeout)
# Poll for completion
try:
result = self.poll_until_complete(
prediction_id,
timeout_seconds=timeout,
interval_seconds=2.0
)
except HTTPException as e:
detail = e.detail or {}
if isinstance(detail, dict):
detail.setdefault("prediction_id", prediction_id)
detail.setdefault("resume_available", True)
raise HTTPException(status_code=e.status_code, detail=detail)
# Extract video URL
outputs = result.get("outputs") or []
if not outputs:
raise HTTPException(
status_code=502,
detail="WAN 2.5 text-to-video completed but returned no outputs"
)
video_url = outputs[0]
if not isinstance(video_url, str) or not video_url.startswith("http"):
raise HTTPException(
status_code=502,
detail=f"Invalid video URL format: {video_url}"
)
# Download video
logger.info(f"[WaveSpeed] Downloading video from: {video_url}")
video_response = requests.get(video_url, timeout=180)
if video_response.status_code != 200:
raise HTTPException(
status_code=502,
detail={
"error": "Failed to download WAN 2.5 video",
"status_code": video_response.status_code,
"response": video_response.text[:200],
}
)
video_bytes = video_response.content
metadata = result.get("metadata") or {}
# Calculate cost (same pricing as image-to-video)
pricing = {
"480p": 0.05,
"720p": 0.10,
"1080p": 0.15,
}
cost = pricing.get(resolution, 0.10) * duration
# Get video dimensions
resolution_dims = {
"480p": (854, 480),
"720p": (1280, 720),
"1080p": (1920, 1080),
}
width, height = resolution_dims.get(resolution, (1280, 720))
logger.info(
f"[WaveSpeed] ✅ Generated text-to-video: {len(video_bytes)} bytes, "
f"resolution={resolution}, duration={duration}s, cost=${cost:.2f}"
)
return {
"video_bytes": video_bytes,
"prompt": prompt,
"duration": float(duration),
"model_name": "alibaba/wan-2.5/text-to-video",
"cost": cost,
"provider": "wavespeed",
"source_video_url": video_url,
"prediction_id": prediction_id,
"resolution": resolution,
"width": width,
"height": height,
"metadata": metadata,
}

View File

@@ -0,0 +1,2 @@
"""YouTube Creator Studio services."""

View File

@@ -0,0 +1,358 @@
"""
YouTube Video Planner Service
Generates video plans, outlines, and insights using AI with persona integration.
"""
from typing import Dict, Any, Optional, List
from loguru import logger
from fastapi import HTTPException
from services.llm_providers.main_text_generation import llm_text_gen
from utils.logger_utils import get_service_logger
logger = get_service_logger("youtube.planner")
class YouTubePlannerService:
"""Service for planning YouTube videos with AI assistance."""
def __init__(self):
"""Initialize the planner service."""
logger.info("[YouTubePlanner] Service initialized")
def generate_video_plan(
self,
user_idea: str,
duration_type: str, # "shorts", "medium", "long"
persona_data: Optional[Dict[str, Any]] = None,
reference_image_description: Optional[str] = None,
source_content_id: Optional[str] = None, # For blog/story conversion
source_content_type: Optional[str] = None, # "blog", "story"
user_id: str = None,
include_scenes: bool = False, # For shorts: combine plan + scenes in one call
) -> Dict[str, Any]:
"""
Generate a comprehensive video plan from user input.
Args:
user_idea: User's video idea or topic
duration_type: "shorts" (≤60s), "medium" (1-4min), "long" (4-10min)
persona_data: Optional persona data for tone/style
reference_image_description: Optional description of reference image
source_content_id: Optional ID of source content (blog/story)
source_content_type: Type of source content
user_id: Clerk user ID for subscription checking
Returns:
Dictionary with video plan, outline, insights, and metadata
"""
try:
logger.info(
f"[YouTubePlanner] Generating plan: idea={user_idea[:50]}..., "
f"duration={duration_type}, user={user_id}"
)
# Build persona context
persona_context = self._build_persona_context(persona_data)
# Build duration context
duration_context = self._get_duration_context(duration_type)
# Build source content context if provided
source_context = ""
if source_content_id and source_content_type:
source_context = f"""
**Source Content:**
- Type: {source_content_type}
- ID: {source_content_id}
- Note: This video should be based on the existing {source_content_type} content.
"""
# Build reference image context
image_context = ""
if reference_image_description:
image_context = f"""
**Reference Image:**
{reference_image_description}
- Use this as visual inspiration for the video
"""
# Generate comprehensive video plan
planning_prompt = f"""You are an expert YouTube content strategist. Create a comprehensive video plan based on the user's idea.
**User's Video Idea:**
{user_idea}
**Video Duration Type:**
{duration_type} ({duration_context['description']})
**Duration Guidelines:**
- Target length: {duration_context['target_seconds']} seconds
- Hook duration: {duration_context['hook_seconds']} seconds
- Main content: {duration_context['main_seconds']} seconds
- CTA duration: {duration_context['cta_seconds']} seconds
- Maximum scenes: {duration_context['max_scenes']} (for shorts, keep 2-4 scenes total)
{persona_context}
{source_context}
{image_context}
**Your Task:**
Create a detailed video plan that includes:
1. **Video Summary**: A 2-3 sentence overview of what the video will cover
2. **Target Audience**: Who this video is for
3. **Video Goal**: Primary objective (educate, entertain, sell, inspire, etc.)
4. **Key Message**: The main takeaway viewers should remember
5. **Hook Strategy**: Attention-grabbing opening (first {duration_context['hook_seconds']} seconds)
6. **Content Outline**: High-level structure with 3-5 main sections
7. **Call-to-Action**: Clear CTA that fits the video goal
8. **Visual Style**: Recommended visual approach (cinematic, tutorial, vlog, etc.)
9. **Tone**: Recommended tone (professional, casual, energetic, etc.)
10. **SEO Keywords**: 5-7 relevant keywords for YouTube SEO
**Format your response as JSON:**
{{
"video_summary": "...",
"target_audience": "...",
"video_goal": "...",
"key_message": "...",
"hook_strategy": "...",
"content_outline": [
{{"section": "Section 1", "description": "...", "duration_estimate": 30}},
{{"section": "Section 2", "description": "...", "duration_estimate": 45}}
],
"call_to_action": "...",
"visual_style": "...",
"tone": "...",
"seo_keywords": ["keyword1", "keyword2", ...]
}}
Make sure the content outline fits within the {duration_type} duration constraints.
"""
system_prompt = (
"You are an expert YouTube content strategist specializing in creating "
"engaging, well-structured video plans. Your plans are data-driven, "
"audience-focused, and optimized for YouTube's algorithm."
)
# For shorts, combine plan + scenes in one call to save API calls
if include_scenes and duration_type == "shorts":
planning_prompt += f"""
**IMPORTANT: Since this is a SHORTS video, also generate the complete scene breakdown in the same response.**
**Additional Task - Generate Detailed Scenes:**
Create detailed scenes (up to {duration_context['max_scenes']} scenes) that include:
1. Scene number and title
2. Narration text (what will be spoken) - keep it concise for shorts
3. Visual description (what viewers will see)
4. Duration estimate (2-8 seconds each)
5. Emphasis tags (hook, main_content, transition, cta)
**Scene Format:**
Each scene should be detailed enough for video generation. Total duration must fit within {duration_context['target_seconds']} seconds.
**Update JSON structure to include "scenes" array:**
Add a "scenes" field with the complete scene breakdown.
"""
json_struct = {
"type": "object",
"properties": {
"video_summary": {"type": "string"},
"target_audience": {"type": "string"},
"video_goal": {"type": "string"},
"key_message": {"type": "string"},
"hook_strategy": {"type": "string"},
"content_outline": {
"type": "array",
"items": {
"type": "object",
"properties": {
"section": {"type": "string"},
"description": {"type": "string"},
"duration_estimate": {"type": "number"}
}
}
},
"call_to_action": {"type": "string"},
"visual_style": {"type": "string"},
"tone": {"type": "string"},
"seo_keywords": {
"type": "array",
"items": {"type": "string"}
},
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"scene_number": {"type": "number"},
"title": {"type": "string"},
"narration": {"type": "string"},
"visual_description": {"type": "string"},
"duration_estimate": {"type": "number"},
"emphasis": {"type": "string"},
"visual_cues": {
"type": "array",
"items": {"type": "string"}
}
},
"required": [
"scene_number", "title", "narration", "visual_description",
"duration_estimate", "emphasis"
]
}
}
},
"required": [
"video_summary", "target_audience", "video_goal", "key_message",
"hook_strategy", "content_outline", "call_to_action",
"visual_style", "tone", "seo_keywords", "scenes"
]
}
else:
json_struct = {
"type": "object",
"properties": {
"video_summary": {"type": "string"},
"target_audience": {"type": "string"},
"video_goal": {"type": "string"},
"key_message": {"type": "string"},
"hook_strategy": {"type": "string"},
"content_outline": {
"type": "array",
"items": {
"type": "object",
"properties": {
"section": {"type": "string"},
"description": {"type": "string"},
"duration_estimate": {"type": "number"}
}
}
},
"call_to_action": {"type": "string"},
"visual_style": {"type": "string"},
"tone": {"type": "string"},
"seo_keywords": {
"type": "array",
"items": {"type": "string"}
}
},
"required": [
"video_summary", "target_audience", "video_goal", "key_message",
"hook_strategy", "content_outline", "call_to_action",
"visual_style", "tone", "seo_keywords"
]
}
# Generate plan using LLM
response = llm_text_gen(
prompt=planning_prompt,
system_prompt=system_prompt,
user_id=user_id,
json_struct=json_struct
)
# Parse response (handle both dict and JSON string)
if isinstance(response, dict):
plan_data = response
else:
import json
plan_data = json.loads(response)
# Add metadata
plan_data["duration_type"] = duration_type
plan_data["duration_metadata"] = duration_context
plan_data["user_idea"] = user_idea
# If scenes were included, mark them for scene builder
if include_scenes and duration_type == "shorts" and "scenes" in plan_data:
plan_data["_scenes_included"] = True
logger.info(
f"[YouTubePlanner] ✅ Plan + {len(plan_data.get('scenes', []))} scenes "
f"generated in 1 AI call (optimized for shorts)"
)
else:
if include_scenes and duration_type == "shorts":
# LLM did not return scenes; downstream will regenerate
plan_data["_scenes_included"] = False
logger.warning(
"[YouTubePlanner] Shorts optimization requested but no scenes returned; "
"scene builder will generate scenes separately."
)
logger.info(f"[YouTubePlanner] ✅ Plan generated successfully")
return plan_data
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubePlanner] Error generating plan: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to generate video plan: {str(e)}"
)
def _build_persona_context(self, persona_data: Optional[Dict[str, Any]]) -> str:
"""Build persona context string for prompts."""
if not persona_data:
return """
**Persona Context:**
- Using default professional tone
- No specific persona constraints
"""
core_persona = persona_data.get("core_persona", {})
tone = core_persona.get("tone", "professional")
voice = core_persona.get("voice_characteristics", {})
return f"""
**Persona Context:**
- Tone: {tone}
- Voice Style: {voice.get('style', 'professional')}
- Communication Style: {voice.get('communication_style', 'clear and direct')}
- Brand Values: {core_persona.get('core_belief', 'value-driven content')}
- Use this persona to guide the video's tone, style, and messaging approach.
"""
def _get_duration_context(self, duration_type: str) -> Dict[str, Any]:
"""Get duration-specific context and constraints."""
contexts = {
"shorts": {
"description": "YouTube Shorts (15-60 seconds)",
"target_seconds": 30,
"hook_seconds": 3,
"main_seconds": 24,
"cta_seconds": 3,
# Keep scenes tight for shorts to control cost and pacing
"max_scenes": 4,
"scene_duration_range": (2, 8)
},
"medium": {
"description": "Medium-length video (1-4 minutes)",
"target_seconds": 150, # 2.5 minutes
"hook_seconds": 10,
"main_seconds": 130,
"cta_seconds": 10,
"max_scenes": 12,
"scene_duration_range": (5, 15)
},
"long": {
"description": "Long-form video (4-10 minutes)",
"target_seconds": 420, # 7 minutes
"hook_seconds": 15,
"main_seconds": 380,
"cta_seconds": 25,
"max_scenes": 20,
"scene_duration_range": (10, 30)
}
}
return contexts.get(duration_type, contexts["medium"])

View File

@@ -0,0 +1,412 @@
"""
YouTube Video Renderer Service
Handles video rendering using WAN 2.5 text-to-video and audio generation.
"""
from typing import Dict, Any, List, Optional
from pathlib import Path
import base64
import uuid
import requests
from loguru import logger
from fastapi import HTTPException
from services.wavespeed.client import WaveSpeedClient
from services.llm_providers.main_audio_generation import generate_audio
from services.story_writer.video_generation_service import StoryVideoGenerationService
from services.subscription import PricingService
from services.subscription.preflight_validator import validate_scene_animation_operation
from services.llm_providers.main_video_generation import track_video_usage
from utils.logger_utils import get_service_logger
from utils.asset_tracker import save_asset_to_library
logger = get_service_logger("youtube.renderer")
class YouTubeVideoRendererService:
"""Service for rendering YouTube videos from scenes."""
def __init__(self):
"""Initialize the renderer service."""
self.wavespeed_client = WaveSpeedClient()
# Video output directory
base_dir = Path(__file__).parent.parent.parent.parent
self.output_dir = base_dir / "youtube_videos"
self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[YouTubeRenderer] Initialized with output directory: {self.output_dir}")
def render_scene_video(
self,
scene: Dict[str, Any],
video_plan: Dict[str, Any],
user_id: str,
resolution: str = "720p",
generate_audio_enabled: bool = True,
voice_id: str = "Wise_Woman",
) -> Dict[str, Any]:
"""
Render a single scene into a video.
Args:
scene: Scene data with narration and visual prompts
video_plan: Original video plan for context
user_id: Clerk user ID
resolution: Video resolution (480p, 720p, 1080p)
generate_audio: Whether to generate narration audio
voice_id: Voice ID for audio generation
Returns:
Dictionary with video metadata, bytes, and cost
"""
try:
scene_number = scene.get("scene_number", 1)
narration = scene.get("narration", "").strip()
visual_prompt = (scene.get("enhanced_visual_prompt") or scene.get("visual_prompt", "")).strip()
duration_estimate = scene.get("duration_estimate", 5)
# VALIDATION: Check inputs before making expensive API calls
if not visual_prompt:
raise HTTPException(
status_code=400,
detail={
"error": f"Scene {scene_number} has no visual prompt",
"scene_number": scene_number,
"message": "Visual prompt is required for video generation",
"user_action": "Please add a visual description for this scene before rendering.",
}
)
if len(visual_prompt) < 10:
logger.warning(
f"[YouTubeRenderer] Scene {scene_number} has very short visual prompt "
f"({len(visual_prompt)} chars), may result in poor quality"
)
# Clamp duration to valid WAN 2.5 values (5 or 10 seconds)
duration = 5 if duration_estimate <= 7 else 10
logger.info(
f"[YouTubeRenderer] Rendering scene {scene_number}: "
f"resolution={resolution}, duration={duration}s, prompt_length={len(visual_prompt)}"
)
# Generate audio if requested - only if narration is not empty
audio_base64 = None
if generate_audio_enabled and narration and len(narration.strip()) > 0:
try:
audio_result = generate_audio(
text=narration,
voice_id=voice_id,
user_id=user_id,
)
# generate_audio may return raw bytes or AudioGenerationResult
audio_bytes = audio_result.audio_bytes if hasattr(audio_result, "audio_bytes") else audio_result
# Convert to base64 (just the base64 string, not data URI)
audio_base64 = base64.b64encode(audio_bytes).decode('utf-8')
logger.info(f"[YouTubeRenderer] Generated audio for scene {scene_number}")
except Exception as e:
logger.warning(f"[YouTubeRenderer] Audio generation failed: {e}, continuing without audio")
# VALIDATION: Final check before expensive video API call
if not visual_prompt or len(visual_prompt.strip()) < 5:
raise HTTPException(
status_code=400,
detail={
"error": f"Scene {scene_number} has invalid visual prompt",
"scene_number": scene_number,
"message": "Visual prompt must be at least 5 characters",
"user_action": "Please provide a valid visual description for this scene.",
}
)
# Generate video using WAN 2.5 text-to-video
# This is the expensive API call - all validation should be done before this
# Use sync mode to wait for result directly (prevents timeout issues)
try:
video_result = self.wavespeed_client.generate_text_video(
prompt=visual_prompt,
resolution=resolution,
duration=duration,
audio_base64=audio_base64, # Optional: enables lip-sync if provided
enable_prompt_expansion=True,
enable_sync_mode=True, # Use sync mode to wait for result directly
timeout=600, # Increased timeout for sync mode (10 minutes)
)
except requests.exceptions.Timeout as e:
logger.error(f"[YouTubeRenderer] WaveSpeed API timed out for scene {scene_number}: {e}")
raise HTTPException(
status_code=504,
detail={
"error": "WaveSpeed request timed out",
"scene_number": scene_number,
"message": "The video generation request timed out.",
"user_action": "Please retry. If it persists, try fewer scenes, lower resolution, or shorter durations.",
},
) from e
except requests.exceptions.RequestException as e:
logger.error(f"[YouTubeRenderer] WaveSpeed API request failed for scene {scene_number}: {e}")
raise HTTPException(
status_code=502,
detail={
"error": "WaveSpeed request failed",
"scene_number": scene_number,
"message": str(e),
"user_action": "Please retry. If it persists, check network connectivity or try again later.",
},
) from e
# Save scene video
video_service = StoryVideoGenerationService(output_dir=str(self.output_dir))
save_result = video_service.save_scene_video(
video_bytes=video_result["video_bytes"],
scene_number=scene_number,
user_id=user_id,
)
# Update video URL to use YouTube API endpoint
filename = save_result["video_filename"]
save_result["video_url"] = f"/api/youtube/videos/{filename}"
# Track usage
usage_info = track_video_usage(
user_id=user_id,
provider=video_result["provider"],
model_name=video_result["model_name"],
prompt=visual_prompt,
video_bytes=video_result["video_bytes"],
cost_override=video_result["cost"],
)
logger.info(
f"[YouTubeRenderer] ✅ Scene {scene_number} rendered: "
f"cost=${video_result['cost']:.2f}, size={len(video_result['video_bytes'])} bytes"
)
return {
"scene_number": scene_number,
"video_filename": save_result["video_filename"],
"video_url": save_result["video_url"],
"video_path": save_result["video_path"],
"duration": video_result["duration"],
"cost": video_result["cost"],
"resolution": resolution,
"width": video_result["width"],
"height": video_result["height"],
"file_size": save_result["file_size"],
"prediction_id": video_result.get("prediction_id"),
"usage_info": usage_info,
}
except HTTPException as e:
# Re-raise with better error message for UI
error_detail = e.detail
if isinstance(error_detail, dict):
error_msg = error_detail.get("error", str(error_detail))
else:
error_msg = str(error_detail)
logger.error(
f"[YouTubeRenderer] Scene {scene_number} failed: {error_msg}",
exc_info=True
)
raise HTTPException(
status_code=e.status_code,
detail={
"error": f"Failed to render scene {scene_number}",
"scene_number": scene_number,
"message": error_msg,
"user_action": "Please try again. If the issue persists, check your scene content and try a different resolution.",
}
)
except Exception as e:
logger.error(f"[YouTubeRenderer] Error rendering scene {scene_number}: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={
"error": f"Failed to render scene {scene_number}",
"scene_number": scene_number,
"message": str(e),
"user_action": "Please try again. If the issue persists, check your scene content and try a different resolution.",
}
)
def render_full_video(
self,
scenes: List[Dict[str, Any]],
video_plan: Dict[str, Any],
user_id: str,
resolution: str = "720p",
combine_scenes: bool = True,
voice_id: str = "Wise_Woman",
) -> Dict[str, Any]:
"""
Render a complete video from multiple scenes.
Args:
scenes: List of scene data
video_plan: Original video plan
user_id: Clerk user ID
resolution: Video resolution
combine_scenes: Whether to combine scenes into single video
voice_id: Voice ID for narration
Returns:
Dictionary with video metadata and scene results
"""
try:
logger.info(
f"[YouTubeRenderer] Rendering full video: {len(scenes)} scenes, "
f"resolution={resolution}, user={user_id}"
)
# Filter enabled scenes
enabled_scenes = [s for s in scenes if s.get("enabled", True)]
if not enabled_scenes:
raise HTTPException(status_code=400, detail="No enabled scenes to render")
scene_results = []
total_cost = 0.0
# Render each scene
for idx, scene in enumerate(enabled_scenes):
logger.info(
f"[YouTubeRenderer] Rendering scene {idx + 1}/{len(enabled_scenes)}: "
f"Scene {scene.get('scene_number', idx + 1)}"
)
scene_result = self.render_scene_video(
scene=scene,
video_plan=video_plan,
user_id=user_id,
resolution=resolution,
generate_audio_enabled=True,
voice_id=voice_id,
)
scene_results.append(scene_result)
total_cost += scene_result["cost"]
# Combine scenes if requested
final_video_path = None
final_video_url = None
if combine_scenes and len(scene_results) > 1:
logger.info("[YouTubeRenderer] Combining scenes into final video...")
# Prepare data for video concatenation
scene_video_paths = [r["video_path"] for r in scene_results]
scene_audio_paths = [r.get("audio_path") for r in scene_results if r.get("audio_path")]
# Use StoryVideoGenerationService to combine
video_service = StoryVideoGenerationService(output_dir=str(self.output_dir))
# Create scene dicts for concatenation
scene_dicts = [
{
"scene_number": r["scene_number"],
"title": f"Scene {r['scene_number']}",
}
for r in scene_results
]
combined_result = video_service.generate_story_video(
scenes=scene_dicts,
image_paths=[None] * len(scene_results), # No static images
audio_paths=scene_audio_paths if scene_audio_paths else [],
video_paths=scene_video_paths, # Use rendered videos
user_id=user_id,
story_title=video_plan.get("video_summary", "YouTube Video")[:50],
fps=24,
)
final_video_path = combined_result["video_path"]
final_video_url = combined_result["video_url"]
logger.info(
f"[YouTubeRenderer] ✅ Full video rendered: {len(scene_results)} scenes, "
f"total_cost=${total_cost:.2f}"
)
return {
"success": True,
"scene_results": scene_results,
"total_cost": total_cost,
"final_video_path": final_video_path,
"final_video_url": final_video_url,
"num_scenes": len(scene_results),
"resolution": resolution,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeRenderer] Error rendering full video: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to render video: {str(e)}"
)
def estimate_render_cost(
self,
scenes: List[Dict[str, Any]],
resolution: str = "720p",
) -> Dict[str, Any]:
"""
Estimate the cost of rendering a video before actually rendering it.
Args:
scenes: List of scene data with duration estimates
resolution: Video resolution (480p, 720p, 1080p)
Returns:
Dictionary with cost breakdown and total estimate
"""
# Pricing per second (same as in WaveSpeedClient)
pricing = {
"480p": 0.05,
"720p": 0.10,
"1080p": 0.15,
}
price_per_second = pricing.get(resolution, 0.10)
# Filter enabled scenes
enabled_scenes = [s for s in scenes if s.get("enabled", True)]
scene_costs = []
total_cost = 0.0
total_duration = 0.0
for scene in enabled_scenes:
scene_number = scene.get("scene_number", 0)
duration_estimate = scene.get("duration_estimate", 5)
# Clamp duration to valid WAN 2.5 values (5 or 10 seconds)
duration = 5 if duration_estimate <= 7 else 10
scene_cost = price_per_second * duration
scene_costs.append({
"scene_number": scene_number,
"duration_estimate": duration_estimate,
"actual_duration": duration,
"cost": round(scene_cost, 2),
})
total_cost += scene_cost
total_duration += duration
return {
"resolution": resolution,
"price_per_second": price_per_second,
"num_scenes": len(enabled_scenes),
"total_duration_seconds": total_duration,
"scene_costs": scene_costs,
"total_cost": round(total_cost, 2),
"estimated_cost_range": {
"min": round(total_cost * 0.9, 2), # 10% buffer
"max": round(total_cost * 1.1, 2), # 10% buffer
},
}

View File

@@ -0,0 +1,551 @@
"""
YouTube Scene Builder Service
Converts video plans into structured scenes with narration, visual prompts, and timing.
"""
from typing import Dict, Any, Optional, List
from loguru import logger
from fastapi import HTTPException
from services.llm_providers.main_text_generation import llm_text_gen
from services.story_writer.prompt_enhancer_service import PromptEnhancerService
from utils.logger_utils import get_service_logger
logger = get_service_logger("youtube.scene_builder")
class YouTubeSceneBuilderService:
"""Service for building structured video scenes from plans."""
def __init__(self):
"""Initialize the scene builder service."""
self.prompt_enhancer = PromptEnhancerService()
logger.info("[YouTubeSceneBuilder] Service initialized")
def build_scenes_from_plan(
self,
video_plan: Dict[str, Any],
user_id: str,
custom_script: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Build structured scenes from a video plan.
Args:
video_plan: Video plan from planner service
user_id: Clerk user ID for subscription checking
custom_script: Optional custom script to use instead of generating
Returns:
List of scene dictionaries with narration, visual prompts, timing, etc.
"""
try:
logger.info(
f"[YouTubeSceneBuilder] Building scenes from plan: "
f"duration={video_plan.get('duration_type')}, "
f"sections={len(video_plan.get('content_outline', []))}"
)
duration_metadata = video_plan.get("duration_metadata", {})
max_scenes = duration_metadata.get("max_scenes", 10)
# If custom script provided, parse it into scenes
if custom_script:
scenes = self._parse_custom_script(
custom_script, video_plan, duration_metadata, user_id
)
# For shorts, check if scenes were already generated in plan (optimization)
elif video_plan.get("_scenes_included") and video_plan.get("duration_type") == "shorts":
prebuilt = video_plan.get("scenes") or []
if prebuilt:
logger.info(
f"[YouTubeSceneBuilder] Using scenes from optimized plan+scenes call "
f"({len(prebuilt)} scenes)"
)
scenes = self._normalize_scenes_from_plan(video_plan, duration_metadata)
else:
logger.warning(
"[YouTubeSceneBuilder] Plan marked _scenes_included but no scenes present; "
"regenerating scenes normally."
)
scenes = self._generate_scenes_from_plan(
video_plan, duration_metadata, user_id
)
else:
# Generate scenes from plan
scenes = self._generate_scenes_from_plan(
video_plan, duration_metadata, user_id
)
# Limit to max scenes
if len(scenes) > max_scenes:
logger.warning(
f"[YouTubeSceneBuilder] Truncating {len(scenes)} scenes to {max_scenes}"
)
scenes = scenes[:max_scenes]
# Enhance visual prompts efficiently based on duration type
duration_type = video_plan.get("duration_type", "medium")
scenes = self._enhance_visual_prompts_batch(
scenes, video_plan, user_id, duration_type
)
logger.info(f"[YouTubeSceneBuilder] ✅ Built {len(scenes)} scenes")
return scenes
except HTTPException:
raise
except Exception as e:
logger.error(f"[YouTubeSceneBuilder] Error building scenes: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to build scenes: {str(e)}"
)
def _generate_scenes_from_plan(
self,
video_plan: Dict[str, Any],
duration_metadata: Dict[str, Any],
user_id: str,
) -> List[Dict[str, Any]]:
"""Generate scenes from video plan using AI."""
content_outline = video_plan.get("content_outline", [])
hook_strategy = video_plan.get("hook_strategy", "")
call_to_action = video_plan.get("call_to_action", "")
visual_style = video_plan.get("visual_style", "cinematic")
tone = video_plan.get("tone", "professional")
scene_duration_range = duration_metadata.get("scene_duration_range", (5, 15))
scene_generation_prompt = f"""You are an expert video scriptwriter. Create detailed scenes for a YouTube video based on this plan.
**Video Plan:**
- Summary: {video_plan.get('video_summary', '')}
- Goal: {video_plan.get('video_goal', '')}
- Key Message: {video_plan.get('key_message', '')}
- Visual Style: {visual_style}
- Tone: {tone}
**Hook Strategy:**
{hook_strategy}
**Content Outline:**
{chr(10).join([f"- {section.get('section', '')}: {section.get('description', '')} ({section.get('duration_estimate', 0)}s)" for section in content_outline])}
**Call-to-Action:**
{call_to_action}
**Duration Constraints:**
- Scene duration: {scene_duration_range[0]}-{scene_duration_range[1]} seconds each
- Total target: {duration_metadata.get('target_seconds', 150)} seconds
**Your Task:**
Create detailed scenes that include:
1. Scene number and title
2. Narration text (what will be spoken)
3. Visual description (what viewers will see)
4. Duration estimate
5. Emphasis tags (hook, main_content, transition, cta)
**Format as JSON array:**
[
{{
"scene_number": 1,
"title": "Hook - Attention Grabber",
"narration": "The spoken text for this scene...",
"visual_description": "Detailed description of what viewers see...",
"duration_estimate": 5,
"emphasis": "hook",
"visual_cues": ["close-up", "dynamic", "bright"]
}},
...
]
Make sure:
- First scene is a strong hook ({duration_metadata.get('hook_seconds', 10)}s)
- Last scene includes the CTA ({duration_metadata.get('cta_seconds', 10)}s)
- Each scene has clear narration and visual description
- Total duration fits within {duration_metadata.get('target_seconds', 150)} seconds
- Scenes flow naturally from one to the next
"""
system_prompt = (
"You are an expert video scriptwriter specializing in YouTube content. "
"Your scenes are engaging, well-paced, and optimized for viewer retention."
)
response = llm_text_gen(
prompt=scene_generation_prompt,
system_prompt=system_prompt,
user_id=user_id,
json_struct={
"type": "array",
"items": {
"type": "object",
"properties": {
"scene_number": {"type": "number"},
"title": {"type": "string"},
"narration": {"type": "string"},
"visual_description": {"type": "string"},
"duration_estimate": {"type": "number"},
"emphasis": {"type": "string"},
"visual_cues": {
"type": "array",
"items": {"type": "string"}
}
},
"required": [
"scene_number", "title", "narration", "visual_description",
"duration_estimate", "emphasis"
]
}
}
)
# Parse response
if isinstance(response, list):
scenes = response
elif isinstance(response, dict) and "scenes" in response:
scenes = response["scenes"]
else:
import json
scenes = json.loads(response) if isinstance(response, str) else response
# Normalize scene data
normalized_scenes = []
for idx, scene in enumerate(scenes, 1):
normalized_scenes.append({
"scene_number": scene.get("scene_number", idx),
"title": scene.get("title", f"Scene {idx}"),
"narration": scene.get("narration", ""),
"visual_description": scene.get("visual_description", ""),
"duration_estimate": scene.get("duration_estimate", scene_duration_range[0]),
"emphasis": scene.get("emphasis", "main_content"),
"visual_cues": scene.get("visual_cues", []),
"visual_prompt": scene.get("visual_description", ""), # Initial prompt
})
return normalized_scenes
def _normalize_scenes_from_plan(
self,
video_plan: Dict[str, Any],
duration_metadata: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""Normalize scenes that were generated as part of the plan (optimization for shorts)."""
scenes = video_plan.get("scenes", [])
scene_duration_range = duration_metadata.get("scene_duration_range", (2, 8))
normalized_scenes = []
for idx, scene in enumerate(scenes, 1):
normalized_scenes.append({
"scene_number": scene.get("scene_number", idx),
"title": scene.get("title", f"Scene {idx}"),
"narration": scene.get("narration", ""),
"visual_description": scene.get("visual_description", ""),
"duration_estimate": scene.get("duration_estimate", scene_duration_range[0]),
"emphasis": scene.get("emphasis", "main_content"),
"visual_cues": scene.get("visual_cues", []),
"visual_prompt": scene.get("visual_description", ""), # Initial prompt
})
logger.info(
f"[YouTubeSceneBuilder] ✅ Normalized {len(normalized_scenes)} scenes "
f"from optimized plan (saved 1 AI call)"
)
return normalized_scenes
def _parse_custom_script(
self,
custom_script: str,
video_plan: Dict[str, Any],
duration_metadata: Dict[str, Any],
user_id: str,
) -> List[Dict[str, Any]]:
"""Parse a custom script into structured scenes."""
# Simple parsing: split by double newlines or scene markers
import re
# Try to detect scene markers
scene_pattern = r'(?:Scene\s+\d+|#\s*\d+\.|^\d+\.)\s*(.+?)(?=(?:Scene\s+\d+|#\s*\d+\.|^\d+\.|$))'
matches = re.finditer(scene_pattern, custom_script, re.MULTILINE | re.DOTALL)
scenes = []
for idx, match in enumerate(matches, 1):
scene_text = match.group(1).strip()
# Extract narration (first paragraph or before visual markers)
narration_match = re.search(r'^(.*?)(?:\n\n|Visual:|Image:)', scene_text, re.DOTALL)
narration = narration_match.group(1).strip() if narration_match else scene_text.split('\n')[0]
# Extract visual description
visual_match = re.search(r'(?:Visual:|Image:)\s*(.+?)(?:\n\n|$)', scene_text, re.DOTALL)
visual_description = visual_match.group(1).strip() if visual_match else narration
scenes.append({
"scene_number": idx,
"title": f"Scene {idx}",
"narration": narration,
"visual_description": visual_description,
"duration_estimate": duration_metadata.get("scene_duration_range", [5, 15])[0],
"emphasis": "hook" if idx == 1 else ("cta" if idx == len(list(matches)) else "main_content"),
"visual_cues": [],
"visual_prompt": visual_description,
})
# Fallback: split by paragraphs if no scene markers
if not scenes:
paragraphs = [p.strip() for p in custom_script.split('\n\n') if p.strip()]
for idx, para in enumerate(paragraphs[:duration_metadata.get("max_scenes", 10)], 1):
scenes.append({
"scene_number": idx,
"title": f"Scene {idx}",
"narration": para,
"visual_description": para,
"duration_estimate": duration_metadata.get("scene_duration_range", [5, 15])[0],
"emphasis": "hook" if idx == 1 else ("cta" if idx == len(paragraphs) else "main_content"),
"visual_cues": [],
"visual_prompt": para,
})
return scenes
def _enhance_visual_prompts_batch(
self,
scenes: List[Dict[str, Any]],
video_plan: Dict[str, Any],
user_id: str,
duration_type: str,
) -> List[Dict[str, Any]]:
"""
Efficiently enhance visual prompts based on video duration type.
Strategy:
- Shorts: Skip enhancement (use original descriptions) - 0 AI calls
- Medium: Batch enhance all scenes in 1 call - 1 AI call
- Long: Batch enhance in 2 calls (split scenes) - 2 AI calls max
"""
# For shorts, skip enhancement to save API calls
if duration_type == "shorts":
logger.info(
f"[YouTubeSceneBuilder] Skipping prompt enhancement for shorts "
f"({len(scenes)} scenes) to save API calls"
)
for scene in scenes:
scene["enhanced_visual_prompt"] = scene.get(
"visual_prompt", scene.get("visual_description", "")
)
return scenes
# Build story context for prompt enhancer
story_context = {
"story_setting": video_plan.get("visual_style", "cinematic"),
"story_tone": video_plan.get("tone", "professional"),
"writing_style": video_plan.get("visual_style", "cinematic"),
}
# Convert scenes to format expected by enhancer
scene_data_list = [
{
"scene_number": scene.get("scene_number", idx + 1),
"title": scene.get("title", ""),
"description": scene.get("visual_description", ""),
"image_prompt": scene.get("visual_prompt", ""),
}
for idx, scene in enumerate(scenes)
]
# For medium videos, enhance all scenes in one batch call
if duration_type == "medium":
logger.info(
f"[YouTubeSceneBuilder] Batch enhancing {len(scenes)} scenes "
f"for medium video in 1 AI call"
)
try:
# Use a single batch enhancement call
enhanced_prompts = self._batch_enhance_prompts(
scene_data_list, story_context, user_id
)
for idx, scene in enumerate(scenes):
scene["enhanced_visual_prompt"] = enhanced_prompts.get(
idx, scene.get("visual_prompt", scene.get("visual_description", ""))
)
except Exception as e:
logger.warning(
f"[YouTubeSceneBuilder] Batch enhancement failed: {e}, "
f"using original prompts"
)
for scene in scenes:
scene["enhanced_visual_prompt"] = scene.get(
"visual_prompt", scene.get("visual_description", "")
)
return scenes
# For long videos, split into 2 batches to avoid token limits
if duration_type == "long":
logger.info(
f"[YouTubeSceneBuilder] Batch enhancing {len(scenes)} scenes "
f"for long video in 2 AI calls"
)
mid_point = len(scenes) // 2
batches = [
scene_data_list[:mid_point],
scene_data_list[mid_point:],
]
all_enhanced = {}
for batch_idx, batch in enumerate(batches):
try:
enhanced = self._batch_enhance_prompts(
batch, story_context, user_id
)
start_idx = 0 if batch_idx == 0 else mid_point
for local_idx, enhanced_prompt in enhanced.items():
all_enhanced[start_idx + local_idx] = enhanced_prompt
except Exception as e:
logger.warning(
f"[YouTubeSceneBuilder] Batch {batch_idx + 1} enhancement "
f"failed: {e}, using original prompts"
)
start_idx = 0 if batch_idx == 0 else mid_point
for local_idx, scene_data in enumerate(batch):
all_enhanced[start_idx + local_idx] = scene_data.get(
"image_prompt", scene_data.get("description", "")
)
for idx, scene in enumerate(scenes):
scene["enhanced_visual_prompt"] = all_enhanced.get(
idx, scene.get("visual_prompt", scene.get("visual_description", ""))
)
return scenes
# Fallback: use original prompts
logger.warning(
f"[YouTubeSceneBuilder] Unknown duration type '{duration_type}', "
f"using original prompts"
)
for scene in scenes:
scene["enhanced_visual_prompt"] = scene.get(
"visual_prompt", scene.get("visual_description", "")
)
return scenes
def _batch_enhance_prompts(
self,
scene_data_list: List[Dict[str, Any]],
story_context: Dict[str, Any],
user_id: str,
) -> Dict[int, str]:
"""
Enhance multiple scene prompts in a single AI call.
Returns:
Dictionary mapping scene index to enhanced prompt
"""
try:
# Build batch enhancement prompt
scenes_text = "\n\n".join([
f"Scene {scene.get('scene_number', idx + 1)}: {scene.get('title', '')}\n"
f"Description: {scene.get('description', '')}\n"
f"Current Prompt: {scene.get('image_prompt', '')}"
for idx, scene in enumerate(scene_data_list)
])
batch_prompt = f"""You are optimizing visual prompts for AI video generation. Enhance the following scenes to be more detailed and video-optimized.
**Video Style Context:**
- Setting: {story_context.get('story_setting', 'cinematic')}
- Tone: {story_context.get('story_tone', 'professional')}
- Style: {story_context.get('writing_style', 'cinematic')}
**Scenes to Enhance:**
{scenes_text}
**Your Task:**
For each scene, create an enhanced visual prompt (200-300 words) that:
1. Is detailed and specific for video generation
2. Includes camera movements, lighting, composition
3. Maintains consistency with the video style
4. Is optimized for WAN 2.5 text-to-video model
**Format as JSON array with enhanced prompts:**
[
{{"scene_index": 0, "enhanced_prompt": "detailed enhanced prompt for scene 1..."}},
{{"scene_index": 1, "enhanced_prompt": "detailed enhanced prompt for scene 2..."}},
...
]
Make sure the array length matches the number of scenes provided ({len(scene_data_list)}).
"""
system_prompt = (
"You are an expert at creating detailed visual prompts for AI video generation. "
"Your prompts are specific, cinematic, and optimized for video models."
)
response = llm_text_gen(
prompt=batch_prompt,
system_prompt=system_prompt,
user_id=user_id,
json_struct={
"type": "array",
"items": {
"type": "object",
"properties": {
"scene_index": {"type": "number"},
"enhanced_prompt": {"type": "string"}
},
"required": ["scene_index", "enhanced_prompt"]
}
}
)
# Parse response
if isinstance(response, list):
enhanced_list = response
elif isinstance(response, str):
import json
enhanced_list = json.loads(response)
else:
enhanced_list = response
# Build result dictionary
result = {}
for item in enhanced_list:
idx = item.get("scene_index", 0)
prompt = item.get("enhanced_prompt", "")
if prompt:
result[idx] = prompt
else:
# Fallback to original
original_scene = scene_data_list[idx] if idx < len(scene_data_list) else {}
result[idx] = original_scene.get(
"image_prompt", original_scene.get("description", "")
)
# Fill in any missing scenes with original prompts
for idx in range(len(scene_data_list)):
if idx not in result:
original_scene = scene_data_list[idx]
result[idx] = original_scene.get(
"image_prompt", original_scene.get("description", "")
)
logger.info(
f"[YouTubeSceneBuilder] ✅ Batch enhanced {len(result)} prompts "
f"in 1 AI call"
)
return result
except Exception as e:
logger.error(
f"[YouTubeSceneBuilder] Batch enhancement failed: {e}",
exc_info=True
)
# Return original prompts as fallback
return {
idx: scene.get("image_prompt", scene.get("description", ""))
for idx, scene in enumerate(scene_data_list)
}

View File

@@ -0,0 +1,187 @@
# AI Podcast Maker - User Experience Enhancements
## ✅ Implemented Enhancements
### 1. **Hidden AI Backend Details**
- **Before**: "WaveSpeed audio rendering", "Google Grounding", "Exa Neural Search"
- **After**:
- "Natural voice narration" instead of "WaveSpeed audio"
- "Standard Research" and "Deep Research" instead of technical provider names
- "Voice" and "Visuals" instead of "TTS" and "Avatars"
- User-friendly descriptions throughout
### 2. **Improved Dashboard Integration**
- Updated `toolCategories.ts` with better description:
- **Old**: "Generate research-grounded podcast scripts and audio"
- **New**: "Create professional podcast episodes with AI-powered research, scriptwriting, and voice narration"
- Updated features list to be user-focused:
- **Old**: ['Research Workflow', 'Editable Script', 'Scene Approvals', 'WaveSpeed Audio']
- **New**: ['AI Research', 'Smart Scripting', 'Voice Narration', 'Export & Share', 'Episode Library']
### 3. **Inline Audio Player**
- Added `InlineAudioPlayer` component that:
- Plays audio directly in the UI (no new tab)
- Shows progress bar with time scrubbing
- Displays current time and duration
- Includes download button
- Better user experience than opening new tabs
### 4. **Enhanced Export & Sharing**
- Download button for completed audio files
- Share button with native sharing API support
- Fallback to clipboard copy if sharing not available
- Proper file naming based on scene title
### 5. **Better Button Labels & Tooltips**
- "Preview Sample" instead of "Preview"
- "Generate Audio" instead of "Start Full Render"
- "Help" instead of "Docs"
- "My Episodes" button for future episode library
- All tooltips explain user benefits, not technical details
### 6. **Improved Cost Display**
- Changed "TTS" to "Voice"
- Changed "Avatars" to "Visuals"
- Added tooltips explaining what each cost item means
- Removed technical provider names from cost display
## 🚀 Recommended Future Enhancements
### High Priority
#### 1. **Episode Templates & Presets**
```typescript
// Suggested templates:
- Interview Style (2 speakers, conversational)
- Educational (1 speaker, structured)
- Storytelling (1 speaker, narrative)
- News/Update (1 speaker, factual)
- Roundtable Discussion (3+ speakers)
```
**Benefits**:
- Faster episode creation
- Consistent quality
- Better for beginners
#### 2. **Episode Library/History**
- Save completed episodes
- View past episodes
- Re-edit or regenerate from saved projects
- Export history
**Implementation**:
- Add backend endpoint to save/load episodes
- Create episode list view
- Add search/filter functionality
#### 3. **Transcript & Show Notes Export**
- Auto-generate transcript from script
- Create show notes with:
- Episode summary
- Key points
- Timestamps
- Links to sources
- Export formats: PDF, Markdown, HTML
#### 4. **Cost Display Improvements**
- Show in credits (if subscription-based)
- "Estimated 5 credits" instead of "$2.50"
- Progress bar showing remaining budget
- Warning when approaching limits
#### 5. **Quick Start Wizard**
- Step-by-step guided creation
- Template selection
- Smart defaults based on template
- Skip advanced options for beginners
### Medium Priority
#### 6. **Real-time Collaboration**
- Share draft episodes with team
- Comments on scenes
- Approval workflow
- Version history
#### 7. **Voice Customization**
- Voice library with samples
- Voice cloning from samples
- Multiple voices per episode
- Voice emotion preview
#### 8. **Smart Editing**
- AI-powered script suggestions
- Grammar and flow improvements
- Pacing recommendations
- Natural pause detection
#### 9. **Analytics & Insights**
- Episode performance metrics
- Listener engagement predictions
- SEO optimization suggestions
- Social sharing optimization
#### 10. **Integration Features**
- Direct upload to podcast platforms (Spotify, Apple Podcasts)
- RSS feed generation
- Social media preview cards
- Blog post integration
### Low Priority / Nice to Have
#### 11. **Background Music**
- Royalty-free music library
- Auto-sync with script pacing
- Fade in/out controls
#### 12. **Multi-language Support**
- Translate scripts
- Generate audio in multiple languages
- Localized voice options
#### 13. **Mobile App**
- Create episodes on the go
- Voice recording integration
- Quick edits
#### 14. **AI Guest Suggestions**
- Suggest relevant experts
- Generate interview questions
- Contact information lookup
## 📋 Implementation Checklist
### Completed ✅
- [x] Hide technical terms (WaveSpeed, Google Grounding, Exa)
- [x] Update dashboard description
- [x] Add inline audio player
- [x] Add download/share buttons
- [x] Improve button labels and tooltips
- [x] Better cost display with user-friendly terms
### Next Steps (Recommended Order)
1. [ ] Episode templates/presets
2. [ ] Episode library backend + UI
3. [ ] Transcript export
4. [ ] Show notes generation
5. [ ] Cost display in credits
6. [ ] Quick start wizard
## 🎯 User Experience Principles Applied
1. **Hide Complexity**: Users don't need to know about "WaveSpeed" or "Minimax" - they just want good audio
2. **Focus on Outcomes**: "Generate Audio" not "Start Full Render"
3. **Provide Context**: Tooltips explain *why* not *how*
4. **Reduce Friction**: Inline player instead of new tabs
5. **Enable Sharing**: Easy export and sharing options
6. **Guide Users**: Clear labels and helpful descriptions
## 💡 Key Insights
- **Technical terms confuse users**: "WaveSpeed" means nothing to end users
- **Actions should be clear**: "Generate Audio" is better than "Start Full Render"
- **Inline experiences are better**: No need to open new tabs for previews
- **Export is essential**: Users need to download and share their work
- **Templates reduce friction**: Most users want quick starts, not full customization

View File

@@ -0,0 +1,295 @@
# Podcast Maker External API Call Analysis
## Overview
This document analyzes all external API calls made during the podcast creation workflow and how they scale with duration, number of speakers, and other factors.
---
## External API Providers
1. **Gemini (Google)** - LLM for story setup and script generation
2. **Google Grounding** - Research via Gemini's native search grounding
3. **Exa** - Alternative neural search provider for research
4. **WaveSpeed** - API gateway for:
- **Minimax Speech 02 HD** - Text-to-Speech (TTS)
- **InfiniteTalk** - Avatar animation (image + audio → video)
---
## Workflow Phases & API Calls
### Phase 1: Project Creation (`createProject`)
**External API Calls:**
1. **Gemini LLM** - Story setup generation
- **Endpoint**: `/api/story/generate-setup`
- **Backend**: `storyWriterApi.generateStorySetup()`
- **Service**: `backend/services/story_writer/service_components/setup.py`
- **Function**: `llm_text_gen()` → Gemini API
- **Calls per project**: **1 call**
- **Scaling**: Fixed (1 call regardless of duration)
2. **Research Config** (Optional)
- **Endpoint**: `/api/research-config`
- **Calls per project**: **0-1 call** (cached)
- **Scaling**: Fixed
**Total Phase 1**: **1-2 external API calls** (fixed)
---
### Phase 2: Research (`runResearch`)
**External API Calls:**
1. **Google Grounding** (via Gemini) OR **Exa Neural Search**
- **Endpoint**: `/api/blog/research/start` → async task
- **Backend**: `blogWriterApi.startResearch()`
- **Service**: `backend/services/blog_writer/research/research_service.py`
- **Provider Selection**:
- **Google Grounding**: Uses Gemini's native Google Search grounding
- **Exa**: Direct Exa API calls
- **Calls per research**: **1 call** (handles all keywords in one request)
- **Scaling**:
- **Fixed per research operation** (1 call regardless of number of queries)
- **Queries are batched** into a single research request
- **Number of queries**: Typically 1-6 (from `mapPersonaQueries`)
**Polling Calls:**
- **Internal task polling**: `blogWriterApi.pollResearchStatus()`
- **Not external API calls** (internal task status checks)
- **Polling frequency**: Every 2.5 seconds, max 120 attempts (5 minutes)
**Total Phase 2**: **1 external API call** (fixed per research operation)
---
### Phase 3: Script Generation (`generateScript`)
**External API Calls:**
1. **Gemini LLM** - Story outline generation
- **Endpoint**: `/api/story/generate-outline`
- **Backend**: `storyWriterApi.generateOutline()`
- **Service**: `backend/services/story_writer/service_components/outline.py`
- **Function**: `llm_text_gen()` → Gemini API
- **Calls per script**: **1 call**
- **Scaling**:
- **Fixed per script generation** (1 call regardless of duration)
- **Duration affects output length** (more scenes), but not number of API calls
**Total Phase 3**: **1 external API call** (fixed)
---
### Phase 4: Audio Rendering (`renderSceneAudio`)
**External API Calls:**
1. **WaveSpeed → Minimax Speech 02 HD** - Text-to-Speech
- **Endpoint**: `/api/story/generate-audio`
- **Backend**: `storyWriterApi.generateAIAudio()`
- **Service**: `backend/services/wavespeed/client.py::generate_speech()`
- **External API**: WaveSpeed API → Minimax Speech 02 HD
- **Calls per scene**: **1 call per scene**
- **Scaling with duration**:
- **Number of scenes** = `Math.ceil((duration * 60) / scene_length_target)`
- **Default scene_length_target**: 45 seconds
- **Example calculations**:
- 5 minutes → `ceil(300 / 45)` = **7 scenes** = **7 TTS calls**
- 10 minutes → `ceil(600 / 45)` = **14 scenes** = **14 TTS calls**
- 15 minutes → `ceil(900 / 45)` = **20 scenes** = **20 TTS calls**
- 30 minutes → `ceil(1800 / 45)` = **40 scenes** = **40 TTS calls**
- **Scaling with speakers**:
- **Fixed per scene** (1 call per scene regardless of speakers)
- **Speakers affect text splitting** (lines per speaker), but not API calls
- **Text length per call**:
- **Characters per scene** ≈ `(scene_length_target * 15)` (assuming ~15 chars/second)
- **5-minute podcast**: ~675 chars/scene × 7 scenes = ~4,725 total chars
- **30-minute podcast**: ~675 chars/scene × 40 scenes = ~27,000 total chars
**Total Phase 4**: **N external API calls** where **N = number of scenes**
---
### Phase 5: Video Rendering (`generateVideo`) - Optional
**External API Calls:**
1. **WaveSpeed → InfiniteTalk** - Avatar animation
- **Endpoint**: `/api/podcast/render/video`
- **Backend**: `podcastApi.generateVideo()`
- **Service**: `backend/services/wavespeed/infinitetalk.py::animate_scene_with_voiceover()`
- **External API**: WaveSpeed API → InfiniteTalk
- **Calls per scene**: **1 call per scene** (if video is generated)
- **Scaling with duration**:
- **Same as audio rendering**: 1 call per scene
- **5 minutes**: **7 video calls**
- **10 minutes**: **14 video calls**
- **15 minutes**: **20 video calls**
- **30 minutes**: **40 video calls**
- **Scaling with speakers**:
- **Fixed per scene** (1 call per scene regardless of speakers)
- **Avatar image is provided** (not generated per speaker)
**Polling Calls:**
- **Internal task polling**: `podcastApi.pollTaskStatus()`
- **Not external API calls** (internal task status checks)
- **Polling frequency**: Every 2.5 seconds until completion (can take up to 10 minutes per video)
**Total Phase 5**: **N external API calls** where **N = number of scenes** (if video is enabled)
---
## Summary: Total External API Calls
### Minimum Workflow (No Video, 5-minute podcast)
1. Project Creation: **1 call** (Gemini - story setup)
2. Research: **1 call** (Google Grounding or Exa)
3. Script Generation: **1 call** (Gemini - outline)
4. Audio Rendering: **7 calls** (Minimax TTS - 7 scenes)
5. Video Rendering: **0 calls** (not enabled)
**Total**: **10 external API calls** for a 5-minute podcast
### Full Workflow (With Video, 5-minute podcast)
1. Project Creation: **1 call** (Gemini - story setup)
2. Research: **1 call** (Google Grounding or Exa)
3. Script Generation: **1 call** (Gemini - outline)
4. Audio Rendering: **7 calls** (Minimax TTS - 7 scenes)
5. Video Rendering: **7 calls** (InfiniteTalk - 7 scenes)
**Total**: **17 external API calls** for a 5-minute podcast
### Scaling with Duration
| Duration | Scenes | Audio Calls | Video Calls | Total (Audio Only) | Total (Audio + Video) |
|----------|--------|-------------|-------------|-------------------|----------------------|
| 5 min | 7 | 7 | 7 | 10 | 17 |
| 10 min | 14 | 14 | 14 | 17 | 31 |
| 15 min | 20 | 20 | 20 | 23 | 43 |
| 30 min | 40 | 40 | 40 | 43 | 83 |
**Formula**:
- **Scenes** = `ceil((duration_minutes * 60) / scene_length_target)`
- **Total (Audio Only)** = `3 + scenes` (3 fixed + N scenes)
- **Total (Audio + Video)** = `3 + (scenes * 2)` (3 fixed + N audio + N video)
---
## Scaling Factors
### 1. Duration
- **Impact**: Linear scaling of rendering calls (audio + video)
- **Fixed calls**: 3 (setup, research, script)
- **Variable calls**: `2 * scenes` (if video enabled) or `1 * scenes` (audio only)
- **Scene count formula**: `ceil((duration * 60) / scene_length_target)`
### 2. Number of Speakers
- **Impact**: **No impact on external API calls**
- **Reason**:
- Text is split into lines per speaker **before** API calls
- Each scene makes **1 TTS call** regardless of speaker count
- Video uses **1 avatar image** (not per speaker)
### 3. Scene Length Target
- **Impact**: Affects number of scenes (and thus rendering calls)
- **Default**: 45 seconds
- **Shorter scenes** = More scenes = More API calls
- **Longer scenes** = Fewer scenes = Fewer API calls
### 4. Research Provider
- **Impact**: **No impact on call count**
- **Google Grounding**: 1 call (batched)
- **Exa**: 1 call (batched)
- **Both**: Same number of calls
### 5. Video Generation
- **Impact**: **Doubles rendering calls** (adds 1 call per scene)
- **Audio only**: `N` calls (N = scenes)
- **Audio + Video**: `2N` calls (N audio + N video)
---
## Cost Implications
### API Call Costs (Estimated)
1. **Gemini LLM** (Story Setup & Script):
- **Setup**: ~2,000 tokens → ~$0.001-0.002
- **Outline**: ~3,000-5,000 tokens → ~$0.002-0.005
- **Total**: ~$0.003-0.007 per podcast
2. **Google Grounding** (Research):
- **Per research**: ~1,200 tokens → ~$0.001-0.002
- **Fixed cost** regardless of query count
3. **Exa Neural Search** (Alternative):
- **Per research**: ~$0.005 (flat rate)
- **Fixed cost** regardless of query count
4. **Minimax TTS** (Audio):
- **Per scene**: ~$0.05 per 1,000 characters
- **5-minute podcast**: ~4,725 chars → ~$0.24
- **30-minute podcast**: ~27,000 chars → ~$1.35
- **Scales linearly with duration**
5. **InfiniteTalk** (Video):
- **Per scene**: ~$0.03-0.06 per second (depending on resolution)
- **5-minute podcast**: 7 scenes × 45s × $0.03 = ~$9.45
- **30-minute podcast**: 40 scenes × 45s × $0.03 = ~$54.00
- **Scales linearly with duration**
### Total Cost Examples
| Duration | Audio Only | Audio + Video (720p) |
|----------|-----------|---------------------|
| 5 min | ~$0.25 | ~$9.50 |
| 10 min | ~$0.50 | ~$19.00 |
| 15 min | ~$0.75 | ~$28.50 |
| 30 min | ~$1.50 | ~$57.00 |
**Note**: Costs are estimates and may vary based on actual API pricing, text length, and video resolution.
---
## Optimization Opportunities
1. **Batch TTS Calls**: Currently 1 call per scene. Could batch multiple scenes if API supports it.
2. **Cache Research Results**: Already implemented for exact keyword matches.
3. **Parallel Rendering**: Audio and video rendering could be parallelized per scene.
4. **Scene Length Optimization**: Longer scenes = fewer API calls (but may reduce quality).
5. **Video Optional**: Video generation doubles costs - make it optional/on-demand.
---
## Internal vs External Calls
### Internal (Not Counted as External)
- Preflight validation checks (`/api/billing/preflight`)
- Task status polling (`/api/story/task/{taskId}/status`)
- Project persistence (`/api/podcast/projects/*`)
- Content asset library (`/api/content-assets/*`)
### External (Counted)
- Gemini LLM (story setup, script generation)
- Google Grounding (research)
- Exa (research alternative)
- WaveSpeed → Minimax TTS (audio)
- WaveSpeed → InfiniteTalk (video)
---
## Conclusion
**Key Findings:**
1. **Fixed overhead**: 3 external API calls per podcast (setup, research, script)
2. **Variable overhead**: 1-2 calls per scene (audio, optionally video)
3. **Duration is the primary scaling factor** for rendering calls
4. **Number of speakers does NOT affect API call count**
5. **Video generation doubles rendering API calls**
**Recommendations:**
- Monitor API call counts and costs per podcast duration
- Consider batching strategies for TTS calls if supported
- Make video generation optional/on-demand to reduce costs
- Optimize scene length to balance quality vs. API call count

View File

@@ -0,0 +1,167 @@
# Podcast Maker - Persistence & Asset Library Integration
## ✅ Phase 1 Implementation Complete
### 1. **Backend Changes**
#### AssetSource Enum Update
- ✅ Added `PODCAST_MAKER = "podcast_maker"` to `backend/models/content_asset_models.py`
- Allows podcast episodes to be tracked in the unified asset library
#### Content Assets API Enhancement
- ✅ Added `POST /api/content-assets/` endpoint in `backend/api/content_assets/router.py`
- Enables frontend to save audio files directly to asset library
- Validates asset_type and source_module enums
- Returns created asset with full metadata
### 2. **Frontend Changes**
#### Persistence Hook (`usePodcastProjectState.ts`)
- ✅ Created comprehensive state management hook
- ✅ Auto-saves to `localStorage` on every state change
- ✅ Restores state on page load/refresh
- ✅ Tracks all project data:
- Project metadata (id, idea, duration, speakers)
- Step results (analysis, queries, research, script)
- Render jobs with status and progress
- Settings (knobs, research provider, budget cap)
- UI state (current step, visibility flags)
- ✅ Handles Set serialization/deserialization for JSON storage
- ✅ Provides helper functions: `resetState`, `initializeProject`
#### Podcast Dashboard Integration
- ✅ Refactored `PodcastDashboard.tsx` to use persistence hook
- ✅ All state now persists automatically
- ✅ Resume alert shows when project is restored
- ✅ "My Episodes" button navigates to Asset Library filtered by podcasts
- ✅ Recent Episodes preview component shows latest 6 episodes
#### Render Queue Enhancement
- ✅ Updated to use persisted render jobs
- ✅ Auto-saves completed audio files to Asset Library
- ✅ Includes metadata: project_id, scene_id, cost, provider, model
- ✅ Proper initialization when moving to render phase
#### Script Editor Enhancement
- ✅ Syncs script changes with persisted state
- ✅ Prevents regeneration if script already exists
- ✅ Scene approvals persist across refreshes
#### Asset Library Integration
- ✅ Updated `AssetLibrary.tsx` to read URL search params
- ✅ Supports filtering by `source_module` and `asset_type` from URL
- ✅ Navigation: `/asset-library?source_module=podcast_maker&asset_type=audio`
### 3. **API Service Updates**
#### Podcast API (`podcastApi.ts`)
- ✅ Added `saveAudioToAssetLibrary()` function
- ✅ Saves audio files with proper metadata
- ✅ Tags assets with project_id for easy filtering
- ✅ Includes cost, provider, and model information
## 🔄 How It Works
### LocalStorage Persistence Flow
1. **User creates project** → State saved to `localStorage` with key `podcast_project_state`
2. **Each step completion** → State automatically updated in `localStorage`
3. **Browser refresh** → State restored from `localStorage` on mount
4. **Resume alert** → Shows which step was in progress
5. **Audio generation** → Completed files saved to Asset Library via API
### Asset Library Integration Flow
1. **Audio render completes**`saveAudioToAssetLibrary()` called
2. **Backend saves asset** → Creates entry in `content_assets` table
3. **Asset appears in library** → Filterable by `source_module=podcast_maker`
4. **User navigates** → "My Episodes" button opens filtered Asset Library view
5. **Unified management** → All podcast episodes visible alongside other content
## 📋 State Structure
```typescript
interface PodcastProjectState {
// Project metadata
project: { id: string; idea: string; duration: number; speakers: number } | null;
// Step results
analysis: PodcastAnalysis | null;
queries: Query[];
selectedQueries: Set<string>;
research: Research | null;
rawResearch: BlogResearchResponse | null;
estimate: PodcastEstimate | null;
scriptData: Script | null;
// Render jobs
renderJobs: Job[];
// Settings
knobs: Knobs;
researchProvider: ResearchProvider;
budgetCap: number;
// UI state
showScriptEditor: boolean;
showRenderQueue: boolean;
currentStep: 'create' | 'analysis' | 'research' | 'script' | 'render' | null;
// Timestamps
createdAt?: string;
updatedAt?: string;
}
```
## 🎯 User Experience
### Resume After Refresh
- User creates project → Works on analysis → Refreshes browser
- ✅ Project state restored
- ✅ Resume alert shows "Resuming from Analysis step"
- ✅ User can continue where they left off
### Resume After Restart
- User completes research → Closes browser → Returns later
- ✅ Project state restored from localStorage
- ✅ All research data available
- ✅ Can proceed to script generation
### Asset Library Access
- User completes episode → Audio saved to library
- ✅ "My Episodes" button shows all podcast episodes
- ✅ Filtered view: `source_module=podcast_maker&asset_type=audio`
- ✅ Can download, share, favorite episodes
- ✅ Unified with all other ALwrity content
## 🚀 Phase 2: Database Persistence (Future)
For long-term persistence across devices/browsers:
1. **Create `podcast_projects` table** or use `content_assets` with project metadata
2. **Add endpoints**:
- `POST /api/podcast/projects` - Save project snapshot
- `GET /api/podcast/projects/{id}` - Load project
- `GET /api/podcast/projects` - List user's projects
3. **Sync strategy**: Save to DB after each major step completion
4. **Resume UI**: Show list of saved projects on dashboard
## ✅ Testing Checklist
- [x] Project state persists after browser refresh
- [x] Resume alert shows correct step
- [x] Script doesn't regenerate if already exists
- [x] Render jobs persist and restore correctly
- [x] Audio files save to Asset Library
- [x] Asset Library filters by podcast_maker
- [x] Navigation to Asset Library works
- [x] Recent Episodes preview displays correctly
- [x] No console errors or warnings
## 📝 Notes
- **localStorage limit**: ~5-10MB per domain. Podcast projects are typically <100KB, so safe.
- **Data loss risk**: localStorage can be cleared by user. Phase 2 (DB persistence) will address this.
- **Cross-device**: localStorage is browser-specific. Phase 2 will enable cross-device access.
- **Performance**: Auto-save happens on every state change. Debouncing could be added if needed.

View File

@@ -0,0 +1,261 @@
# AI Podcast Maker Integration Plan - Completion Status
## Overview
This document tracks the completion status of each item in the AI Podcast Maker Integration Plan.
---
## 1. Backend Discovery & Interfaces ✅ **COMPLETED**
**Status**: ✅ Complete
**Completed Items**:
- ✅ Reviewed existing services in `backend/services/wavespeed/`, `backend/services/minimax/`
- ✅ Reviewed research adapters (Google Grounding, Exa)
- ✅ Documented REST routes in `backend/api/story_writer/`, `backend/api/blog_writer/`
- ✅ Created `docs/AI_PODCAST_BACKEND_REFERENCE.md` with comprehensive API documentation
**Evidence**:
- `docs/AI_PODCAST_BACKEND_REFERENCE.md` exists and catalogs all relevant endpoints
- `frontend/src/services/podcastApi.ts` uses real backend endpoints
- Backend services properly integrated
---
## 2. Frontend Data Layer Refactor ✅ **COMPLETED**
**Status**: ✅ Complete
**Completed Items**:
- ✅ Replaced all mock helpers with real API wrappers in `podcastApi.ts`
- ✅ Integrated with `aiApiClient` and `pollingApiClient` for backend communication
- ✅ Implemented job polling helper (`waitForTaskCompletion`) for async research/render jobs
- ✅ All API calls use real endpoints (createProject, runResearch, generateScript, renderSceneAudio)
**Evidence**:
- `frontend/src/services/podcastApi.ts` - All functions use real API calls
- No mock data remaining in the codebase
- Proper error handling and async job polling implemented
---
## 3. Subscription & Cost Safeguards ⚠️ **PARTIALLY COMPLETED**
**Status**: ⚠️ Partial - Preflight checks implemented, but UI blocking needs enhancement
**Completed Items**:
- ✅ Pre-flight validation implemented (`ensurePreflight` function)
- ✅ Preflight checks before research (`runResearch`) - lines 286-291
- ✅ Preflight checks before script generation (`generateScript`) - lines 307-312
- ✅ Preflight checks before render operations (`renderSceneAudio`) - lines 373-378
- ✅ Preflight checks before preview (`previewLine`) - lines 344-349
- ✅ Cost estimation function (`estimateCosts`) implemented
- ✅ Estimate displayed in UI
**Missing/Incomplete Items**:
- ⚠️ UI blocking when preflight fails - errors are thrown but UI doesn't proactively prevent actions
- ⚠️ Budget cap enforcement - budget cap is set but not enforced before expensive operations
- ⚠️ Subscription tier-based UI restrictions - HD/multi-speaker modes not hidden for lower tiers
- ⚠️ Preflight validation UI feedback - users don't see why operations are blocked
**Evidence**:
- `frontend/src/services/podcastApi.ts` lines 210-217, 286-291, 307-312, 344-349, 373-378 show preflight checks
- `frontend/src/components/PodcastMaker/PodcastDashboard.tsx` shows estimate but no proactive blocking UI
**Recommendations**:
- Add UI blocking before render operations if preflight fails
- Enforce budget cap before expensive operations
- Hide premium features based on subscription tier
---
## 4. Research Workflow Integration ✅ **COMPLETED**
**Status**: ✅ Complete
**Completed Items**:
- ✅ "Generate queries" wired to backend (uses `storyWriterApi.generateStorySetup`)
- ✅ "Run research" wired to backend Google Grounding & Exa routes
- ✅ Query selection UI implemented
- ✅ Research provider selection (Google/Exa) implemented
- ✅ Async research jobs handled with polling (`waitForTaskCompletion`)
- ✅ Fact cards map correctly to script lines
- ✅ Error/timeout handling implemented
**Evidence**:
- `frontend/src/services/podcastApi.ts` lines 265-297 - `runResearch` function
- `frontend/src/components/PodcastMaker/PodcastDashboard.tsx` - Research UI with provider selection
- Research polling uses `blogWriterApi.pollResearchStatus`
---
## 5. Script Authoring & Approvals ✅ **COMPLETED**
**Status**: ✅ Complete
**Completed Items**:
- ✅ Script generation tied to story writer script API (Gemini-based)
- ✅ Scene IDs persisted from backend
- ✅ Scene approval toggles replaced with actual `/script/approve` API calls
- ✅ Backend gating matches UI state (`approveScene` function)
- ✅ TTS preview implemented using Minimax/WaveSpeed (`previewLine` function)
**Evidence**:
- `frontend/src/services/podcastApi.ts` lines 299-360 - `generateScript` function
- `frontend/src/services/podcastApi.ts` lines 404-411 - `approveScene` function
- `frontend/src/services/podcastApi.ts` lines 362-400 - `previewLine` function
- `backend/api/story_writer/routes/story_content.py` - Scene approval endpoint
---
## 6. Rendering Pipeline ⚠️ **PARTIALLY COMPLETED**
**Status**: ⚠️ Partial - Audio rendering works, but video/avatar rendering not implemented
**Completed Items**:
- ✅ Preview/full render buttons connected to WaveSpeed/Minimax render routes
- ✅ Scene content, knob settings supplied to render API
- ✅ Audio rendering working (`renderSceneAudio`)
- ✅ Render job status tracking in UI
- ✅ Audio files saved to asset library
**Missing/Incomplete Items**:
- ❌ Video rendering not implemented (only audio)
- ❌ Avatar rendering not implemented
- ❌ Job polling for render progress (`/media/jobs/{jobId}`) not implemented
- ❌ Render cancellation not implemented
- ⚠️ Polling intervals cleanup on unmount - needs verification
**Evidence**:
- `frontend/src/services/podcastApi.ts` lines 413-451 - `renderSceneAudio` function
- `frontend/src/components/PodcastMaker/RenderQueue.tsx` - Render queue UI
- Audio generation works, but video/avatar features not implemented
**Recommendations**:
- Implement video rendering using WaveSpeed InfiniteTalk
- Add avatar rendering support
- Implement job polling for long-running render operations
- Add cancellation support
---
## 7. Testing & Telemetry ⚠️ **PARTIALLY COMPLETED**
**Status**: ⚠️ Partial - Logging integrated, but no formal tests
**Completed Items**:
- ✅ Logging integrated with centralized logger (backend uses `loguru`)
- ✅ Error handling and user feedback implemented
- ✅ Structured events for observability (backend logging)
**Missing/Incomplete Items**:
- ❌ Integration tests not created
- ❌ Storybook fixtures not created
- ❌ UI transition tests not implemented
- ❌ Error state tests not implemented
**Evidence**:
- Backend services use `loguru` logger
- Frontend has error handling but no tests
- No test files found for podcast maker
**Recommendations**:
- Create integration tests for API endpoints
- Add Storybook fixtures for UI components
- Test UI transitions and error states
---
## 8. Rollout Considerations ⚠️ **PARTIALLY COMPLETED**
**Status**: ⚠️ Partial - Basic fallbacks exist, but subscription tier restrictions not implemented
**Completed Items**:
- ✅ Fallback to stock voices if voice cloning unavailable
- ✅ Basic error handling and graceful degradation
**Missing/Incomplete Items**:
- ❌ Subscription tier validation not implemented
- ❌ HD quality options not hidden for lower plans
- ❌ Multi-speaker modes not restricted by subscription tier
- ❌ Quality options not filtered by user tier
**Evidence**:
- `frontend/src/components/PodcastMaker/CreateModal.tsx` - Quality options always visible
- No subscription tier checks in UI
- No tier-based feature restrictions
**Recommendations**:
- Add subscription tier checks before showing premium options
- Hide HD/multi-speaker for lower tiers
- Add tier-based UI restrictions
---
## Summary
### Overall Completion: ~75%
**Fully Completed (5/8)**:
1. ✅ Backend Discovery & Interfaces
2. ✅ Frontend Data Layer Refactor
3. ✅ Research Workflow Integration
4. ✅ Script Authoring & Approvals
5. ✅ Database Persistence (Phase 2 - Bonus)
**Partially Completed (4/8)**:
1. ⚠️ Subscription & Cost Safeguards (80% - preflight checks exist, needs better UI feedback and budget enforcement)
2. ⚠️ Rendering Pipeline (60% - audio works, video/avatar missing, no job polling)
3. ⚠️ Testing & Telemetry (40% - logging yes, tests no)
4. ⚠️ Rollout Considerations (30% - basic fallbacks, no tier restrictions)
### Priority Next Steps:
1. **High Priority**:
- Add UI blocking for preflight validation failures
- Implement budget cap enforcement
- Add subscription tier-based UI restrictions
2. **Medium Priority**:
- Implement video rendering (WaveSpeed InfiniteTalk)
- Add render job polling for progress tracking
- Implement render cancellation
3. **Low Priority**:
- Create integration tests
- Add Storybook fixtures
- Comprehensive error state testing
---
## Additional Completed Items (Beyond Original Plan)
### Phase 2 - Database Persistence ✅ **COMPLETED**
- ✅ Database model created (`PodcastProject`)
- ✅ API endpoints for save/load/list projects
- ✅ Automatic database sync after major steps
- ✅ Project list view for resume
- ✅ Cross-device persistence working
### UI/UX Enhancements ✅ **COMPLETED**
- ✅ Modern AI-like styling with MUI and Tailwind
- ✅ Compact UI design
- ✅ Well-written tooltips and messages
- ✅ Progress stepper visualization
- ✅ Component refactoring for maintainability
### Asset Library Integration ✅ **COMPLETED**
- ✅ Completed audio files saved to asset library
- ✅ Asset Library filtering by podcast source
- ✅ "My Episodes" navigation button
---
## Notes
- The core functionality is working and production-ready
- Audio generation is fully functional
- Database persistence enables cross-device resume
- UI is modern and user-friendly
- Main gaps are in video/avatar rendering and subscription tier restrictions

View File

@@ -0,0 +1,101 @@
# YouTube Creator AI Call Optimization Report
## Current AI Call Analysis
### 1. Video Planning (`planner.py`)
- **Current**: 1 AI call (`llm_text_gen`) to generate video plan
- **Status**: ✅ Optimized - Single call for complete plan
- **Optimization Potential**: None (necessary for quality)
### 2. Scene Generation (`scene_builder.py`)
- **Current**:
- 1 AI call (`llm_text_gen`) to generate all scenes
- Enhancement calls based on duration:
- Shorts: 0 calls (skip enhancement) ✅
- Medium: 1 call (batch enhancement) ✅
- Long: 2 calls (split batch enhancement) ✅
- **Status**: ✅ Already optimized
- **Optimization Potential**: Combine plan + scenes for shorts (save 1 call)
### 3. Audio Generation (`renderer.py`)
- **Current**: 1 external API call per scene (`generate_audio`)
- **Status**: ⚠️ Can be optimized
- **Optimization Potential**:
- Shorts: Batch all narrations into 1-2 calls
- Medium/Long: Batch narrations in groups of 3-5 scenes
### 4. Video Generation (`renderer.py`)
- **Current**: 1 external API call per scene (`generate_text_video` - WaveSpeed)
- **Status**: ✅ Cannot optimize (API limitation - one video per call)
- **Optimization Potential**: None (external API constraint)
## Optimization Strategy
### Shorts (≤60 seconds, ~8 scenes)
**Current**: 1 (plan) + 1 (scenes) + 0 (enhancement) + 8 (audio) = **10 calls**
**Optimized**: 1 (plan+scenes combined) + 0 (enhancement) + 2 (batched audio) = **3 calls**
**Savings**: 70% reduction (7 fewer calls)
### Medium (1-4 minutes, ~12 scenes)
**Current**: 1 (plan) + 1 (scenes) + 1 (enhancement) + 12 (audio) = **15 calls**
**Optimized**: 1 (plan) + 1 (scenes) + 1 (enhancement) + 3 (batched audio) = **6 calls**
**Savings**: 60% reduction (9 fewer calls)
### Long (4-10 minutes, ~20 scenes)
**Current**: 1 (plan) + 1 (scenes) + 2 (enhancement) + 20 (audio) = **24 calls**
**Optimized**: 1 (plan) + 1 (scenes) + 2 (enhancement) + 5 (batched audio) = **9 calls**
**Savings**: 62.5% reduction (15 fewer calls)
## Implementation Plan
1. ✅ Combine plan + scene generation for shorts (save 1 call) - **IMPLEMENTED**
2. ⚠️ Audio generation: Cannot batch (each scene needs separate audio file - external API limitation)
3. ✅ Keep video generation as-is (external API limitation)
## Final Optimized Call Counts
### Shorts (≤60 seconds, ~8 scenes)
**Before**: 1 (plan) + 1 (scenes) + 0 (enhancement) + 8 (audio) = **10 calls**
**After**: 1 (plan+scenes combined) + 0 (enhancement) + 8 (audio) = **9 calls**
**Savings**: 10% reduction (1 fewer call)
**Note**: Audio calls are necessary per scene (external API limitation)
### Medium (1-4 minutes, ~12 scenes)
**Before**: 1 (plan) + 1 (scenes) + 1 (enhancement) + 12 (audio) = **15 calls**
**After**: 1 (plan) + 1 (scenes) + 1 (enhancement) + 12 (audio) = **15 calls**
**Savings**: Already optimized (enhancement batched)
**Note**: Audio calls are necessary per scene (external API limitation)
### Long (4-10 minutes, ~20 scenes)
**Before**: 1 (plan) + 1 (scenes) + 2 (enhancement) + 20 (audio) = **24 calls**
**After**: 1 (plan) + 1 (scenes) + 2 (enhancement) + 20 (audio) = **24 calls**
**Savings**: Already optimized (enhancement batched)
**Note**: Audio calls are necessary per scene (external API limitation)
## Key Optimizations Implemented
1. **Shorts Optimization**: Combined plan + scene generation into single AI call
- Saves 1 LLM text generation call
- Maintains quality by generating both in one comprehensive prompt
2. **Scene Enhancement Batching**: Already optimized
- Shorts: Skip enhancement (0 calls)
- Medium: Batch all scenes (1 call)
- Long: Split into 2 batches (2 calls)
3. **Audio Generation**: Cannot be optimized further
- Each scene requires separate audio file
- External API (WaveSpeed) limitation - one audio per call
- This is necessary for quality (each scene has unique narration)
4. **Video Generation**: Cannot be optimized
- External API (WaveSpeed WAN 2.5) limitation
- One video per API call is required
## Quality Preservation
All optimizations maintain output quality:
- Combined plan+scenes for shorts uses comprehensive prompt
- Batch enhancement maintains scene consistency
- No quality loss from optimizations

View File

@@ -0,0 +1,405 @@
# YouTube Creator Studio - Completion Review & Enhancement Plan
## 📊 Implementation Summary
### ✅ Completed Features
#### Backend Services
1. **YouTube Planner Service** (`backend/services/youtube/planner.py`)
- AI-powered video plan generation
- Persona integration for tone/style
- Duration-aware planning (shorts/medium/long)
- Source content conversion (blog/story → video)
- Reference image support
2. **YouTube Scene Builder Service** (`backend/services/youtube/scene_builder.py`)
- Converts plans into structured scenes
- Narration generation per scene
- Visual prompt enhancement
- Custom script parsing support
- Emphasis tags (hook, main_content, cta)
3. **YouTube Video Renderer Service** (`backend/services/youtube/renderer.py`)
- WAN 2.5 text-to-video integration
- Audio generation with voice selection
- Scene-by-scene rendering
- Video concatenation (combine scenes)
- Usage tracking and cost calculation
- Asset library integration
#### API Endpoints (`backend/api/youtube/router.py`)
- `POST /api/youtube/plan` - Generate video plan
- `POST /api/youtube/scenes` - Build scenes from plan
- `POST /api/youtube/scenes/{id}/update` - Update individual scene
- `POST /api/youtube/render` - Start async video rendering
- `GET /api/youtube/render/{task_id}` - Get render status
- `GET /api/youtube/videos/{filename}` - Serve generated videos
#### Frontend Components
- **YouTube Creator Studio** (`frontend/src/components/YouTubeCreator/YouTubeCreator.tsx`)
- 3-step workflow (Plan → Scenes → Render)
- Scene editing interface
- Real-time render progress
- Video preview and download
- Resolution selection (480p/720p/1080p)
- Voice selection
- Scene enable/disable toggle
#### Integration Points
- ✅ Dashboard navigation (Generate Content → Video)
- ✅ Persona system integration
- ✅ Subscription validation
- ✅ Asset tracking
- ✅ Usage tracking
- ✅ Task manager for async operations
---
## 🔍 Low-Hanging Features to Consolidate
### 1. **Error Handling & Retry Logic** ⚠️ HIGH PRIORITY
**Current State**: Basic error handling, no retry logic for video generation
**Opportunity**: Add robust retry with exponential backoff (like `ProductImageService`)
**Implementation**:
- Add retry wrapper in `YouTubeVideoRendererService.render_scene_video()`
- Handle transient API errors (503, timeouts)
- Skip retries for validation errors (4xx)
- Update task status with retry attempts
**Files to Modify**:
- `backend/services/youtube/renderer.py`
- Add `_render_with_retry()` method
### 2. **Video Generation Service Consolidation** 🔄 MEDIUM PRIORITY
**Current State**: YouTube renderer duplicates some logic from `StoryVideoGenerationService`
**Opportunity**: Extract common video operations into shared service
**Shared Operations**:
- Video concatenation
- Audio/video synchronization
- File saving patterns
- Progress callbacks
**Files to Consider**:
- `backend/services/story_writer/video_generation_service.py`
- `backend/services/youtube/renderer.py`
- Create: `backend/services/shared/video_utils.py`
### 3. **Blog Writer → YouTube Integration** 🎯 HIGH PRIORITY
**Current State**: API supports `source_content_id` but no UI integration
**Opportunity**: Add "Create Video" button in Blog Writer export phase
**Implementation**:
- Add button in `BlogExport.tsx` or similar
- Pre-fill YouTube Creator with blog content
- Use blog title/outline as video plan input
- Map blog sections to video scenes
**Files to Modify**:
- `frontend/src/components/BlogWriter/Phases/BlogExport.tsx`
- `backend/api/youtube/router.py` (already supports this)
### 4. **Scene Preview & Thumbnail Generation** 🖼️ MEDIUM PRIORITY
**Current State**: No preview of scenes before rendering
**Opportunity**: Generate thumbnail images for each scene
**Implementation**:
- Use existing image generation to create scene thumbnails
- Show thumbnails in scene review step
- Allow regeneration of individual thumbnails
**Files to Add**:
- `backend/services/youtube/thumbnail_service.py`
- Update `YouTubeCreator.tsx` to show thumbnails
### 5. **Video Templates & Presets** 📋 LOW PRIORITY
**Current State**: All videos start from scratch
**Opportunity**: Pre-built templates for common video types
**Templates**:
- Product Demo
- Tutorial/How-To
- Explainer Video
- Testimonial
- Social Media Short
**Implementation**:
- Add template selection in Step 1
- Pre-fill plan with template structure
- Allow customization
### 6. **Batch Scene Regeneration** 🔄 MEDIUM PRIORITY
**Current State**: Must regenerate all scenes if one fails
**Opportunity**: Regenerate individual scenes without losing others
**Implementation**:
- Add "Regenerate Scene" button per scene
- Keep other scenes intact
- Update scene in place
### 7. **Cost Estimation Before Rendering** 💰 HIGH PRIORITY
**Current State**: Cost only shown after rendering
**Opportunity**: Show estimated cost before starting render
**Implementation**:
- Calculate cost based on:
- Number of scenes
- Resolution
- Duration estimates
- Show cost breakdown in Step 3
- Warn if approaching subscription limits
**Files to Modify**:
- `backend/api/youtube/router.py` - Add `/estimate-cost` endpoint
- `frontend/src/components/YouTubeCreator/YouTubeCreator.tsx`
### 8. **Video Analytics & Optimization Suggestions** 📊 LOW PRIORITY
**Current State**: No post-generation insights
**Opportunity**: Provide YouTube optimization tips
**Features**:
- SEO score for video plan
- Hook effectiveness analysis
- CTA strength rating
- Duration optimization suggestions
### 9. **Multi-Language Support** 🌍 MEDIUM PRIORITY
**Current State**: English only
**Opportunity**: Leverage WAN 2.5 multilingual capabilities
**Implementation**:
- Add language selector in Step 1
- Pass language to planner/scene builder
- Use appropriate voice for language
### 10. **Video Export Formats** 📦 LOW PRIORITY
**Current State**: MP4 only
**Opportunity**: Export in multiple formats
**Formats**:
- MP4 (current)
- WebM (web optimized)
- MOV (professional)
- GIF (for previews)
---
## 🚀 New Features to Add
### 1. **YouTube Shorts Optimizer** ⭐ HIGH VALUE
**Description**: Specialized mode for YouTube Shorts with vertical format (9:16)
**Features**:
- Automatic aspect ratio detection
- Vertical video generation (1080x1920)
- Hook-first scene prioritization
- Subtitle generation
- Trending hashtag suggestions
**Implementation**:
- Add "Shorts Mode" toggle
- Modify renderer to use vertical resolution
- Add subtitle overlay service
### 2. **A/B Testing for Hooks** 🧪 MEDIUM VALUE
**Description**: Generate multiple hook variations and test
**Features**:
- Generate 3-5 hook variations
- Side-by-side comparison
- User selects best hook
- Use selected hook in final video
### 3. **Video Script Export** 📝 LOW VALUE
**Description**: Export narration as script file
**Formats**:
- SRT (subtitles)
- VTT (WebVTT)
- TXT (plain text)
- DOCX (formatted)
### 4. **Collaborative Editing** 👥 LOW PRIORITY
**Description**: Share video projects for team review
**Features**:
- Share project link
- Comment on scenes
- Approve/reject scenes
- Version history
### 5. **AI-Powered Scene Transitions** ✨ MEDIUM VALUE
**Description**: Smart transitions between scenes
**Features**:
- Analyze scene content
- Suggest transition type (fade, cut, zoom)
- Apply transitions automatically
- Custom transition library
---
## 🔧 Robustness Improvements
### 1. **Better Error Messages**
- **Current**: Generic error messages
- **Improvement**: Context-specific errors with recovery suggestions
- **Example**: "Scene 3 failed: API timeout. Would you like to retry this scene?"
### 2. **Partial Success Handling**
- **Current**: All-or-nothing rendering
- **Improvement**: Continue rendering other scenes if one fails
- **Show**: Which scenes succeeded/failed
- **Allow**: Re-render only failed scenes
### 3. **Progress Granularity**
- **Current**: Overall progress percentage
- **Improvement**: Per-scene progress with ETA
- **Show**: Current operation (generating audio, rendering video, combining)
### 4. **Resume Failed Renders**
- **Current**: Must restart from beginning
- **Improvement**: Resume from last successful scene
- **Store**: Progress in task manager
- **Resume**: On task restart
### 5. **Video Quality Validation**
- **Current**: No validation before serving
- **Improvement**: Validate video file integrity
- **Check**: File size, duration, codec
- **Warn**: If video seems corrupted
### 6. **Rate Limiting & Queue Management**
- **Current**: No queue for concurrent requests
- **Improvement**: Queue system for video rendering
- **Limit**: Max concurrent renders per user
- **Show**: Position in queue
---
## 📈 Metrics & Analytics
### Track These Metrics:
1. **Generation Success Rate**: % of successful video renders
2. **Average Render Time**: Per scene and full video
3. **Cost per Video**: Average cost breakdown
4. **User Drop-off Points**: Where users abandon workflow
5. **Most Used Features**: Scene editing, resolution selection, etc.
6. **Error Frequency**: Most common errors and causes
### Dashboard to Add:
- Video generation history
- Cost tracking
- Success rate trends
- Popular video types
---
## 🎯 Priority Ranking
### Phase 1: Critical (Do First)
1. ✅ Error handling & retry logic
2. ✅ Cost estimation before rendering
3. ✅ Blog Writer → YouTube integration
4. ✅ Partial success handling
### Phase 2: High Value (Next Sprint)
5. ✅ Scene preview/thumbnails
6. ✅ YouTube Shorts optimizer
7. ✅ Better error messages
8. ✅ Resume failed renders
### Phase 3: Nice to Have (Future)
9. ✅ Video templates
10. ✅ A/B testing for hooks
11. ✅ Multi-language support
12. ✅ Analytics dashboard
---
## 🔗 Integration Opportunities
### Existing Systems to Leverage:
1. **Story Writer Video Service**: Reuse video concatenation logic
2. **Image Generation**: For scene thumbnails
3. **Audio Generation**: Already integrated
4. **Asset Library**: Already integrated
5. **Subscription System**: Already integrated
6. **Persona System**: Already integrated
### New Integrations to Consider:
1. **Content Calendar**: Schedule video generation
2. **SEO Dashboard**: Video SEO optimization
3. **Social Media Scheduler**: Direct YouTube upload
4. **Analytics Integration**: YouTube Analytics API
---
## 📝 Documentation Needs
1. **API Documentation**: OpenAPI/Swagger updates
2. **User Guide**: Step-by-step tutorial
3. **Video Tutorial**: Screen recording of workflow
4. **Developer Guide**: How to extend YouTube Creator
5. **Troubleshooting Guide**: Common issues and solutions
---
## 🧪 Testing Checklist
### Unit Tests Needed:
- [ ] Planner service with various inputs
- [ ] Scene builder with edge cases
- [ ] Renderer error handling
- [ ] Cost calculation accuracy
### Integration Tests Needed:
- [ ] Full workflow end-to-end
- [ ] Blog → YouTube conversion
- [ ] Multi-scene rendering
- [ ] Error recovery
### E2E Tests Needed:
- [ ] User creates video from idea
- [ ] User edits scenes
- [ ] User renders and downloads
- [ ] User converts blog to video
---
## 💡 Quick Wins (Can Do Today)
1. **Add cost estimation endpoint** (1-2 hours)
2. **Improve error messages** (1 hour)
3. **Add scene count validation** (30 mins)
4. **Add loading states** (30 mins)
5. **Add keyboard shortcuts** (1 hour)
---
## 📊 Completion Status
- **Backend Services**: ✅ 100% Complete
- **API Endpoints**: ✅ 100% Complete
- **Frontend UI**: ✅ 100% Complete
- **Error Handling**: ⚠️ 60% Complete (needs retry logic)
- **Documentation**: ⚠️ 40% Complete (needs user guide)
- **Testing**: ⚠️ 20% Complete (needs comprehensive tests)
- **Integration**: ⚠️ 50% Complete (Blog Writer integration pending)
**Overall Completion**: ~75%
---
## 🎉 Summary
The YouTube Creator Studio is **functionally complete** and ready for production use. The core workflow works end-to-end, but there are several **low-hanging improvements** that would significantly enhance robustness and user experience:
1. **Error handling** with retries
2. **Cost estimation** before rendering
3. **Blog Writer integration** for content conversion
4. **Better progress feedback** and partial success handling
These improvements can be implemented incrementally without disrupting the existing functionality.

View File

@@ -12,6 +12,7 @@ import FacebookWriter from './components/FacebookWriter/FacebookWriter';
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
import BlogWriter from './components/BlogWriter/BlogWriter';
import StoryWriter from './components/StoryWriter/StoryWriter';
import YouTubeCreator from './components/YouTubeCreator/YouTubeCreator';
import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard } from './components/ImageStudio';
import { ProductMarketingDashboard } from './components/ProductMarketing';
import PodcastDashboard from './components/PodcastMaker/PodcastDashboard';
@@ -453,6 +454,7 @@ const App: React.FC = () => {
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
<Route path="/story-writer" element={<ProtectedRoute><StoryWriter /></ProtectedRoute>} />
<Route path="/youtube-creator" element={<ProtectedRoute><YouTubeCreator /></ProtectedRoute>} />
<Route path="/podcast-maker" element={<ProtectedRoute><PodcastDashboard /></ProtectedRoute>} />
<Route path="/image-studio" element={<ProtectedRoute><ImageStudioDashboard /></ProtectedRoute>} />
<Route path="/image-generator" element={<ProtectedRoute><CreateStudio /></ProtectedRoute>} />

View File

@@ -1,4 +1,5 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Box,
Paper,
@@ -120,6 +121,12 @@ const getStatusChip = (status: string) => {
};
export const AssetLibrary: React.FC = () => {
const [searchParams] = useSearchParams();
// Initialize filters from URL params if present
const urlSourceModule = searchParams.get('source_module');
const urlAssetType = searchParams.get('asset_type');
const [searchQuery, setSearchQuery] = useState('');
const [idSearch, setIdSearch] = useState('');
const [modelSearch, setModelSearch] = useState('');
@@ -127,7 +134,13 @@ export const AssetLibrary: React.FC = () => {
const [debouncedSearch, setDebouncedSearch] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list'); // Default to list like reference
const [tabValue, setTabValue] = useState(0);
const [filterType, setFilterType] = useState('all');
const [filterType, setFilterType] = useState(() => {
// Set filter type from URL if present
if (urlAssetType) {
return urlAssetType === 'audio' ? 'audio' : urlAssetType === 'image' ? 'images' : urlAssetType === 'video' ? 'videos' : urlAssetType === 'text' ? 'text' : 'all';
}
return 'all';
});
const [statusFilter, setStatusFilter] = useState('all');
const [selectedAssets, setSelectedAssets] = useState<Set<number>>(new Set());
const [page, setPage] = useState(0);
@@ -156,6 +169,11 @@ export const AssetLibrary: React.FC = () => {
offset: page * pageSize,
};
// Apply source_module from URL if present
if (urlSourceModule) {
baseFilters.source_module = urlSourceModule as any;
}
// Combine all search terms
const searchTerms: string[] = [];
if (debouncedSearch) searchTerms.push(debouncedSearch);
@@ -179,7 +197,7 @@ export const AssetLibrary: React.FC = () => {
}
return baseFilters;
}, [debouncedSearch, idSearch, modelSearch, filterType, tabValue, page, pageSize]);
}, [debouncedSearch, idSearch, modelSearch, filterType, tabValue, page, pageSize, urlSourceModule]);
const { assets, loading, error, total, toggleFavorite, deleteAsset, trackUsage, refetch } = useContentAssets(filters);

View File

@@ -38,6 +38,7 @@ import { motion, type Variants, type Easing } from 'framer-motion';
import { useTransformStudio } from '../../hooks/useTransformStudio';
import { ImageStudioLayout } from './ImageStudioLayout';
import { OperationButton } from '../shared/OperationButton';
import { PreflightOperation } from '../../services/billingService';
const MotionPaper = motion(Paper);
const MotionCard = motion(Card);
@@ -146,6 +147,19 @@ export const TransformStudio: React.FC = () => {
return imageBase64 && audioBase64;
}, [imageBase64, audioBase64]);
// Define preflight operations for cost estimation
const imageToVideoOperation: PreflightOperation = useMemo(() => ({
provider: 'wavespeed',
model: 'alibaba/wan-2.5/image-to-video',
operation_type: 'image-to-video',
}), []);
const talkingAvatarOperation: PreflightOperation = useMemo(() => ({
provider: 'wavespeed',
model: 'wavespeed-ai/infinitetalk',
operation_type: 'talking-avatar',
}), []);
const handleEstimateCost = useCallback(async () => {
if (tabValue === 0) {
// Image-to-video
@@ -510,13 +524,13 @@ export const TransformStudio: React.FC = () => {
Estimate Cost
</Button>
<OperationButton
operation={imageToVideoOperation}
label="Generate Video"
onClick={handleGenerate}
disabled={!canGenerateImageToVideo || isGenerating}
loading={isGenerating}
fullWidth
>
Generate Video
</OperationButton>
/>
</Stack>
</Stack>
</MotionCard>
@@ -583,7 +597,6 @@ export const TransformStudio: React.FC = () => {
startIcon={<Upload />}
fullWidth
sx={{ py: 2 }}
required
>
{audioBase64 ? 'Change Audio' : 'Upload Audio (Required)'}
</Button>
@@ -709,13 +722,13 @@ export const TransformStudio: React.FC = () => {
Estimate Cost
</Button>
<OperationButton
operation={talkingAvatarOperation}
label="Generate Avatar"
onClick={handleGenerate}
disabled={!canGenerateTalkingAvatar || isGenerating}
loading={isGenerating}
fullWidth
>
Generate Avatar
</OperationButton>
/>
</Stack>
</Stack>
</MotionCard>

View File

@@ -269,9 +269,14 @@ const GenerateChip: React.FC<{
const IconComponent = chip.icon;
const navigate = useNavigate();
const handleClick = () => {
if (chip.label === 'Today' && onTodayClick) {
onTodayClick();
} else if (chip.label === 'Video') {
// Navigate to YouTube Creator
navigate('/youtube-creator');
}
};
@@ -446,6 +451,8 @@ const GeneratePillarChips: React.FC<{
index: number;
isHovered?: boolean;
}> = ({ index, isHovered = false }) => {
const navigate = useNavigate();
// Generate pillar Today tasks
const generateTodayTasks: TodayTask[] = [
{
@@ -461,7 +468,7 @@ const GeneratePillarChips: React.FC<{
icon: FacebookIcon,
color: '#1877F2',
enabled: true,
action: () => console.log('Navigate to Facebook writer')
action: () => navigate('/facebook-writer')
},
{
id: 'blog-post',
@@ -491,7 +498,22 @@ const GeneratePillarChips: React.FC<{
icon: LinkedInIcon,
color: '#0077B5',
enabled: true,
action: () => console.log('Navigate to LinkedIn writer')
action: () => navigate('/linkedin-writer')
},
{
id: 'youtube-video',
pillarId: 'generate',
title: 'Create YouTube Video with AI',
description: 'Generate AI-powered YouTube videos from your ideas',
status: 'pending' as const,
priority: 'high' as const,
estimatedTime: 25,
actionType: 'navigate' as const,
actionUrl: '/youtube-creator',
icon: VideoIcon,
color: '#E91E63',
enabled: true,
action: () => navigate('/youtube-creator')
}
];
@@ -546,6 +568,16 @@ const GeneratePillarChips: React.FC<{
delay={index * 5}
/>
{/* Video Chip - Always Visible (Primary Action) */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
style={{ marginTop: '8px' }}
>
<GenerateChip chip={generateChips.video} delay={index * 5 + 1} />
</motion.div>
{/* More Options Indicator */}
{!isHovered && (
<motion.div
@@ -591,13 +623,6 @@ const GeneratePillarChips: React.FC<{
>
<GenerateChip chip={generateChips.audio} delay={index * 5 + 3} />
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
<GenerateChip chip={generateChips.video} delay={index * 5 + 4} />
</motion.div>
</Box>
</motion.div>
)}

View File

@@ -0,0 +1,157 @@
import React from "react";
import { Stack, Box, Typography, Divider, Chip, Paper, alpha } from "@mui/material";
import { Psychology as PsychologyIcon, Insights as InsightsIcon } from "@mui/icons-material";
import { PodcastAnalysis } from "./types";
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
import { Refresh as RefreshIcon } from "@mui/icons-material";
interface AnalysisPanelProps {
analysis: PodcastAnalysis | null;
onRegenerate?: () => void;
}
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, onRegenerate }) => {
if (!analysis) return null;
return (
<GlassyCard
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28 }}
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a",
}}
aria-label="analysis-panel"
>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography
variant="h6"
sx={{
color: "#0f172a",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<PsychologyIcon />
AI Analysis
</Typography>
<Typography variant="body2" color="text.secondary">
Insights derived from AI analysis of your topic and content preferences
</Typography>
</Box>
<SecondaryButton onClick={onRegenerate} startIcon={<RefreshIcon />} tooltip="Regenerate analysis with different parameters">
Regenerate
</SecondaryButton>
</Stack>
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 0.5 }}>
<InsightsIcon fontSize="small" sx={{ color: "#4f46e5" }} />
Target Audience
</Typography>
<Typography variant="body2" sx={{ color: "#0f172a" }}>
{analysis.audience}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Content Type</Typography>
<Chip label={analysis.contentType} size="small" sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }} />
</Box>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Top Keywords</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{analysis.topKeywords.map((k) => (
<Chip
key={k}
label={k}
size="small"
variant="outlined"
sx={{
borderColor: "rgba(0,0,0,0.1)",
color: "#0f172a",
background: "#f8fafc",
}}
/>
))}
</Stack>
</Box>
</Stack>
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Suggested Episode Outlines</Typography>
<Stack spacing={1.5}>
{analysis.suggestedOutlines.map((o) => (
<Paper
key={o.id}
sx={{
p: 1.5,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.06)",
wordBreak: "break-word",
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5, color: "#0f172a", wordBreak: "break-word" }}>
{o.title}
</Typography>
<Typography variant="caption" sx={{ color: "#475569", display: "block", wordBreak: "break-word" }}>
{o.segments.join(" • ")}
</Typography>
</Paper>
))}
</Stack>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Title Suggestions</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{analysis.titleSuggestions.map((t) => (
<Chip
key={t}
label={t}
size="small"
sx={{
cursor: "pointer",
color: "#0f172a",
background: "#f8fafc",
maxWidth: "100%",
whiteSpace: "normal",
height: "auto",
lineHeight: 1.3,
"& .MuiChip-label": {
whiteSpace: "normal",
wordBreak: "break-word",
textAlign: "left",
paddingTop: 0.25,
paddingBottom: 0.25,
},
"&:hover": {
background: alpha("#667eea", 0.15),
border: "1px solid rgba(102,126,234,0.35)",
},
}}
/>
))}
</Stack>
</Box>
</Stack>
</Box>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,333 @@
import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, TextField, Divider, Button, Alert, alpha, Tooltip, Paper, Chip } from "@mui/material";
import {
AutoAwesome as AutoAwesomeIcon,
Refresh as RefreshIcon,
Info as InfoIcon,
} from "@mui/icons-material";
import { CreateProjectPayload, Knobs } from "./types";
import { PrimaryButton, SecondaryButton } from "./ui";
import { useSubscription } from "../../contexts/SubscriptionContext";
interface CreateModalProps {
onCreate: (payload: CreateProjectPayload) => void;
open: boolean;
defaultKnobs: Knobs;
isSubmitting?: boolean;
}
export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false }) => {
const { subscription } = useSubscription();
const [idea, setIdea] = useState("");
const [url, setUrl] = useState("");
const [showAIDetailsButton, setShowAIDetailsButton] = useState(false);
const [speakers, setSpeakers] = useState<number>(1);
const [duration, setDuration] = useState<number>(10);
const [budgetCap, setBudgetCap] = useState<number>(50);
const [voiceFile, setVoiceFile] = useState<File | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
// Determine subscription tier restrictions
const tier = subscription?.tier || 'free';
const isFreeTier = tier === 'free';
const isBasicTier = tier === 'basic';
const canUseHD = !isFreeTier && !isBasicTier; // HD only for pro/enterprise
const canUseMultiSpeaker = !isFreeTier; // Multi-speaker for basic+ tiers
// Reset HD quality if user downgrades
useEffect(() => {
if (!canUseHD && knobs.bitrate === 'hd') {
setKnobs({ ...knobs, bitrate: 'standard' });
}
}, [canUseHD]);
// Reset multi-speaker if user downgrades
useEffect(() => {
if (!canUseMultiSpeaker && speakers > 1) {
setSpeakers(1);
}
}, [canUseMultiSpeaker]);
// Show AI details button when user starts typing
useEffect(() => {
setShowAIDetailsButton(idea.trim().length > 0);
}, [idea]);
const canSubmit = Boolean(idea || url);
const submit = () => {
if (!canSubmit || isSubmitting) return;
onCreate({
ideaOrUrl: idea || url,
speakers,
duration,
knobs,
budgetCap,
files: { voiceFile, avatarFile },
});
};
const reset = () => {
setIdea("");
setUrl("");
setSpeakers(1);
setDuration(10);
setBudgetCap(50);
setVoiceFile(null);
setAvatarFile(null);
setKnobs({ ...defaultKnobs });
};
return (
<Paper
elevation={0}
sx={{
borderRadius: 3,
border: "1px solid rgba(0,0,0,0.08)",
background: "#ffffff",
boxShadow: "0 6px 20px rgba(15, 23, 42, 0.08)",
p: { xs: 3, md: 4 },
}}
>
<Stack spacing={2}>
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between" flexWrap="wrap">
<Stack direction="row" spacing={2} alignItems="center">
<AutoAwesomeIcon sx={{ color: "#667eea" }} />
<Box>
<Typography variant="h5" sx={{ color: "#0f172a", fontWeight: 800 }}>
Create New Podcast Episode
</Typography>
<Typography variant="body2" color="text.secondary">
Provide either a topic idea or a blog post URL. We start AI analysis only after you click Analyze & Continue.
</Typography>
</Box>
</Stack>
<Stack direction="row" spacing={1}>
<Chip label={`Plan: ${subscription?.tier || "free"}`} size="small" color="default" />
<Chip label={`Duration: ${duration} min`} size="small" color="default" />
<Chip label={`${speakers} speaker${speakers > 1 ? "s" : ""}`} size="small" color="default" />
</Stack>
</Stack>
<Alert severity="info" sx={{ background: "#eef2ff", border: "1px solid #e0e7ff" }}>
<Typography variant="body2" sx={{ color: "#4338ca" }}>
Tips for best results:
</Typography>
<Typography variant="body2" sx={{ color: "#4338ca" }}>
Provide one clear topic OR a single blog URL (we wont auto-run anything).<br />
Keep it conciseone sentence topic works best.<br />
We start analysis only after you confirm, so you stay in control.
</Typography>
</Alert>
<Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="stretch">
{/* Topic Idea Section */}
<Box flex={1}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}>
Topic Idea
</Typography>
<Tooltip
title="Enter a concise idea. We will expand it into an outline only after you click Analyze."
arrow
placement="top"
>
<TextField
fullWidth
multiline
rows={5}
placeholder="e.g., 'How AI is transforming content marketing in 2024'"
inputProps={{
sx: {
"&::placeholder": { color: "#94a3b8", opacity: 1 },
color: "#0f172a",
},
}}
value={idea}
onChange={(e) => {
setIdea(e.target.value);
// Clear URL when typing idea
if (e.target.value.trim().length > 0) {
setUrl("");
}
}}
size="small"
helperText="We will not start analysis until you click Analyze."
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
"&:hover": {
backgroundColor: "#f1f5f9",
},
"& .MuiOutlinedInput-input": {
fontSize: "0.95rem",
lineHeight: 1.5,
color: "#0f172a",
},
},
"& .MuiInputBase-input::placeholder": {
color: "#94a3b8",
opacity: 1,
},
"& .MuiFormHelperText-root": {
color: "#475569",
},
}}
/>
</Tooltip>
{/* Add details with AI button - appears when user types */}
{showAIDetailsButton && (
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<AutoAwesomeIcon />}
onClick={() => {
// TODO: Implement AI details functionality
console.log("Add details with AI clicked");
}}
sx={{
textTransform: "none",
fontSize: "0.875rem",
borderColor: "#667eea",
color: "#667eea",
"&:hover": {
borderColor: "#5568d3",
backgroundColor: alpha("#667eea", 0.08),
},
}}
>
Add details with AI
</Button>
</Box>
)}
</Box>
{/* Center OR divider */}
<Stack alignItems="center" justifyContent="center" sx={{ px: { xs: 0, md: 1 } }}>
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", md: "block" }, borderColor: "rgba(0,0,0,0.08)" }} />
<Divider sx={{ display: { xs: "block", md: "none" }, borderColor: "rgba(0,0,0,0.08)", my: 1 }} />
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 700, fontSize: "0.75rem" }}>
OR
</Typography>
</Stack>
{/* Blog URL Section */}
<Box flex={1}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}>
Blog Post URL
</Typography>
<Tooltip
title="Paste a single article URL. Well fetch insights only after you click Analyze."
arrow
placement="top"
>
<TextField
fullWidth
label="Paste blog post URL"
placeholder="https://yourblog.com/article"
inputProps={{
sx: {
"&::placeholder": { color: "#94a3b8", opacity: 1 },
color: "#0f172a",
},
}}
value={url}
onChange={(e) => {
setUrl(e.target.value);
// Clear idea when entering URL
if (e.target.value.trim().length > 0) {
setIdea("");
setShowAIDetailsButton(false);
}
}}
size="small"
helperText="We wont trigger analysis until you confirm."
InputProps={{
endAdornment: (
<Tooltip title="One URL is enough—keep it focused to reduce retries." arrow>
<InfoIcon sx={{ color: "action.disabled", fontSize: 18, ml: 1 }} />
</Tooltip>
),
}}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
"&:hover": {
backgroundColor: "#f1f5f9",
},
},
"& .MuiInputBase-input::placeholder": {
color: "#94a3b8",
opacity: 1,
},
"& .MuiFormHelperText-root": {
color: "#475569",
},
}}
/>
</Tooltip>
</Box>
</Stack>
{/* Quick settings for duration and speakers */}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Duration (minutes)"
type="number"
value={duration}
onChange={(e) => setDuration(Math.max(1, Number(e.target.value) || 0))}
InputProps={{ inputProps: { min: 1, max: 60 } }}
size="small"
helperText="Typical podcasts: 5-20 minutes"
sx={{
maxWidth: 220,
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
"&:hover": { backgroundColor: "#f1f5f9" },
},
}}
/>
<TextField
label="Number of speakers"
type="number"
value={speakers}
onChange={(e) => setSpeakers(Math.min(4, Math.max(1, Number(e.target.value) || 1)))}
InputProps={{ inputProps: { min: 1, max: 4 } }}
size="small"
helperText="Supports single or panel style"
sx={{
maxWidth: 220,
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
"&:hover": { backgroundColor: "#f1f5f9" },
},
}}
/>
</Stack>
<Alert severity="info" sx={{ background: "#ecfeff", border: "1px solid #bae6fd", borderRadius: 1 }}>
<Typography variant="body2" sx={{ fontSize: "0.9rem", color: "#0ea5e9" }}>
You can provide either a topic idea or a blog post URL. We wont make any external AI calls until you click Analyze & Continue.
</Typography>
</Alert>
<Stack direction="row" justifyContent="flex-end" spacing={1}>
<SecondaryButton onClick={reset} startIcon={<RefreshIcon />}>
Reset
</SecondaryButton>
<PrimaryButton
onClick={submit}
disabled={!canSubmit || isSubmitting}
loading={isSubmitting}
startIcon={<AutoAwesomeIcon />}
tooltip={!canSubmit ? "Enter an idea or URL to continue" : "Well start AI analysis after this click"}
>
{isSubmitting ? "Analyzing..." : "Analyze & Continue"}
</PrimaryButton>
</Stack>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,92 @@
import React, { useMemo } from "react";
import { Stack, Typography, Divider, Chip, Tooltip, IconButton, alpha } from "@mui/material";
import { OpenInNew as OpenInNewIcon, ContentCopy as ContentCopyIcon } from "@mui/icons-material";
import { Fact } from "./types";
import { GlassyCard, glassyCardSx } from "./ui";
interface FactCardProps {
fact: Fact;
}
export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
const hostname = useMemo(() => {
try {
return new URL(fact.url).hostname;
} catch {
return fact.url;
}
}, [fact.url]);
const handleCopy = () => {
navigator.clipboard.writeText(fact.quote);
};
return (
<GlassyCard
whileHover={{ y: -4 }}
sx={{
...glassyCardSx,
p: 2,
cursor: "pointer",
transition: "all 0.2s",
"&:hover": {
borderColor: "rgba(102,126,234,0.25)",
boxShadow: "0 12px 28px rgba(15,23,42,0.08)",
},
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
}}
>
<Stack spacing={1.5}>
<Typography variant="body2" sx={{ lineHeight: 1.6, color: "#0f172a" }}>
{fact.quote}
</Typography>
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
<Stack direction="row" spacing={1} alignItems="center" flex={1}>
<OpenInNewIcon fontSize="small" sx={{ color: "rgba(15,23,42,0.6)" }} />
<Typography
variant="caption"
component="a"
href={fact.url}
target="_blank"
rel="noreferrer"
sx={{
color: "#4f46e5",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{hostname || "source"}
</Typography>
</Stack>
<Tooltip title="Copy citation">
<IconButton size="small" onClick={handleCopy} sx={{ color: "rgba(15,23,42,0.65)" }}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Stack direction="row" spacing={2}>
<Chip
label={`${(fact.confidence * 100).toFixed(0)}% confidence`}
size="small"
sx={{
height: 20,
fontSize: "0.65rem",
background: alpha("#22c55e", 0.15),
color: "#15803d",
border: "1px solid rgba(34,197,94,0.35)",
}}
/>
<Typography variant="caption" sx={{ color: "#475569" }}>
{fact.date}
</Typography>
</Stack>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,118 @@
import React, { useEffect, useState } from "react";
import { Paper, Stack, Typography, IconButton, Tooltip, alpha } from "@mui/material";
import { VolumeUp as VolumeUpIcon, PlayCircle as PlayCircleIcon, PauseCircle as PauseCircleIcon, Download as DownloadIcon } from "@mui/icons-material";
interface InlineAudioPlayerProps {
audioUrl: string;
title?: string;
}
export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl, title }) => {
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const audioRef = React.useRef<HTMLAudioElement>(null);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const updateTime = () => setCurrentTime(audio.currentTime);
const updateDuration = () => setDuration(audio.duration);
const handleEnd = () => setPlaying(false);
audio.addEventListener("timeupdate", updateTime);
audio.addEventListener("loadedmetadata", updateDuration);
audio.addEventListener("ended", handleEnd);
return () => {
audio.removeEventListener("timeupdate", updateTime);
audio.removeEventListener("loadedmetadata", updateDuration);
audio.removeEventListener("ended", handleEnd);
};
}, [audioUrl]);
const togglePlay = () => {
const audio = audioRef.current;
if (!audio) return;
if (playing) {
audio.pause();
} else {
audio.play();
}
setPlaying(!playing);
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const audio = audioRef.current;
if (!audio) return;
const newTime = parseFloat(e.target.value);
audio.currentTime = newTime;
setCurrentTime(newTime);
};
return (
<Paper
sx={{
p: 2,
background: alpha("#1e293b", 0.6),
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 2,
}}
>
<Stack spacing={1.5}>
{title && (
<Typography variant="subtitle2" sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<VolumeUpIcon fontSize="small" />
{title}
</Typography>
)}
<Stack direction="row" spacing={2} alignItems="center">
<IconButton onClick={togglePlay} sx={{ color: "#a78bfa" }} size="large">
{playing ? <PauseCircleIcon fontSize="large" /> : <PlayCircleIcon fontSize="large" />}
</IconButton>
<Stack flex={1}>
<input
type="range"
min={0}
max={duration || 0}
value={currentTime}
onChange={handleSeek}
style={{ width: "100%", cursor: "pointer" }}
/>
<Stack direction="row" justifyContent="space-between" sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{formatTime(currentTime)}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatTime(duration)}
</Typography>
</Stack>
</Stack>
<Tooltip title="Download audio">
<IconButton
onClick={() => {
const link = document.createElement("a");
link.href = audioUrl;
link.download = title || "podcast-audio.mp3";
link.click();
}}
sx={{ color: "rgba(255,255,255,0.7)" }}
>
<DownloadIcon />
</IconButton>
</Tooltip>
</Stack>
<audio ref={audioRef} src={audioUrl} preload="metadata" />
</Stack>
</Paper>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Typography,
Button,
Box,
Alert,
Stack,
alpha,
} from '@mui/material';
import {
Block as BlockIcon,
Upgrade as UpgradeIcon,
Info as InfoIcon,
} from '@mui/icons-material';
import { PreflightCheckResponse } from '../../services/billingService';
import { useNavigate } from 'react-router-dom';
interface PreflightBlockDialogProps {
open: boolean;
onClose: () => void;
response: PreflightCheckResponse | null;
operationName?: string;
}
export const PreflightBlockDialog: React.FC<PreflightBlockDialogProps> = ({
open,
onClose,
response,
operationName = 'This operation',
}) => {
const navigate = useNavigate();
if (!response) return null;
const blockedOperation = response.operations.find((op) => !op.allowed);
const message = blockedOperation?.message || 'Operation blocked by subscription limits';
const limitInfo = blockedOperation?.limit_info;
const handleUpgrade = () => {
navigate('/pricing');
onClose();
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
background: alpha('#0f172a', 0.95),
backdropFilter: 'blur(20px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 4,
},
}}
>
<DialogTitle>
<Stack direction="row" spacing={2} alignItems="center">
<BlockIcon sx={{ color: '#ef4444', fontSize: 32 }} />
<Box>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
Operation Blocked
</Typography>
<Typography variant="body2" color="text.secondary">
{operationName} cannot proceed
</Typography>
</Box>
</Stack>
</DialogTitle>
<DialogContent>
<Stack spacing={2}>
<Alert severity="error" sx={{ background: alpha('#ef4444', 0.1), border: '1px solid rgba(239,68,68,0.3)' }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{message}
</Typography>
</Alert>
{limitInfo && (
<Box sx={{ p: 2, background: alpha('#667eea', 0.1), borderRadius: 2, border: '1px solid rgba(102,126,234,0.3)' }}>
<Stack spacing={1}>
<Typography variant="subtitle2" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<InfoIcon fontSize="small" />
Usage Limits
</Typography>
<Typography variant="body2" color="text.secondary">
Current: {limitInfo.current_usage.toLocaleString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Limit: {limitInfo.limit.toLocaleString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Remaining: {limitInfo.remaining.toLocaleString()}
</Typography>
</Stack>
</Box>
)}
{response.estimated_cost > 0 && (
<Box sx={{ p: 2, background: alpha('#f59e0b', 0.1), borderRadius: 2, border: '1px solid rgba(245,158,11,0.3)' }}>
<Typography variant="body2" color="text.secondary">
Estimated Cost: ${response.estimated_cost.toFixed(4)}
</Typography>
</Box>
)}
</Stack>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 2 }}>
<Button onClick={onClose} variant="outlined">
Close
</Button>
<Button
onClick={handleUpgrade}
variant="contained"
startIcon={<UpgradeIcon />}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'&:hover': {
background: 'linear-gradient(135deg, #5568d3 0%, #6a4190 100%)',
},
}}
>
Upgrade Plan
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,343 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Paper,
Stack,
Typography,
Button,
Chip,
CircularProgress,
Alert,
alpha,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
} from "@mui/material";
import {
Mic as MicIcon,
PlayArrow as PlayArrowIcon,
Delete as DeleteIcon,
Star as StarIcon,
StarBorder as StarBorderIcon,
Refresh as RefreshIcon,
Search as SearchIcon,
} from "@mui/icons-material";
import { podcastApi } from "../../services/podcastApi";
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
interface Project {
id: number;
project_id: string;
idea: string;
duration: number;
speakers: number;
current_step: string | null;
status: string;
is_favorite: boolean;
created_at: string;
updated_at: string;
}
interface ProjectListProps {
onSelectProject: (projectId: string) => void;
}
export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) => {
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const loadProjects = async () => {
try {
setLoading(true);
setError(null);
const response = await podcastApi.listProjects({
order_by: "updated_at",
limit: 50,
});
setProjects(response.projects);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load projects");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadProjects();
}, []);
const handleDelete = async () => {
if (!projectToDelete) return;
try {
await podcastApi.deleteProject(projectToDelete);
await loadProjects();
setDeleteDialogOpen(false);
setProjectToDelete(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete project");
}
};
const handleToggleFavorite = async (projectId: string, currentFavorite: boolean) => {
try {
await podcastApi.toggleFavorite(projectId);
await loadProjects();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update favorite");
}
};
const getStepLabel = (step: string | null) => {
switch (step) {
case "analysis":
return "Analysis";
case "research":
return "Research";
case "script":
return "Script";
case "render":
return "Rendering";
default:
return "Draft";
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "completed":
return "success";
case "in_progress":
return "info";
case "draft":
return "default";
default:
return "default";
}
};
const filteredProjects = projects.filter((project) =>
project.idea.toLowerCase().includes(searchQuery.toLowerCase()) ||
project.project_id.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<Box
sx={{
minHeight: "100vh",
background: "linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)",
p: { xs: 2, md: 4 },
}}
>
<Paper
elevation={0}
sx={{
maxWidth: 1400,
mx: "auto",
borderRadius: 4,
border: "1px solid rgba(255,255,255,0.08)",
background: alpha("#0f172a", 0.7),
backdropFilter: "blur(25px)",
p: { xs: 3, md: 4 },
}}
>
<Stack spacing={3}>
{/* Header */}
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box>
<Typography
variant="h3"
sx={{
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1.5,
}}
>
<MicIcon fontSize="large" />
My Podcast Projects
</Typography>
<Typography variant="body2" color="text.secondary">
Resume your work or start a new episode
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<SecondaryButton onClick={loadProjects} startIcon={<RefreshIcon />} disabled={loading}>
Refresh
</SecondaryButton>
<PrimaryButton onClick={() => navigate("/podcast-maker")} startIcon={<PlayArrowIcon />}>
New Episode
</PrimaryButton>
</Stack>
</Stack>
{/* Search */}
<TextField
fullWidth
placeholder="Search projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: <SearchIcon sx={{ color: "rgba(255,255,255,0.5)", mr: 1 }} />,
}}
sx={{
"& .MuiOutlinedInput-root": {
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
},
}}
/>
{/* Error */}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Loading */}
{loading && (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
)}
{/* Projects List */}
{!loading && filteredProjects.length === 0 && (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2} alignItems="center" sx={{ p: 4 }}>
<Typography variant="h6" color="text.secondary">
{searchQuery ? "No projects match your search" : "No projects yet"}
</Typography>
<PrimaryButton onClick={() => navigate("/podcast-maker")} startIcon={<PlayArrowIcon />}>
Create Your First Episode
</PrimaryButton>
</Stack>
</GlassyCard>
)}
{!loading && filteredProjects.length > 0 && (
<Stack spacing={2}>
{filteredProjects.map((project) => (
<GlassyCard
key={project.project_id}
sx={{
...glassyCardSx,
cursor: "pointer",
"&:hover": {
borderColor: "rgba(102,126,234,0.4)",
transform: "translateY(-2px)",
},
transition: "all 0.2s",
}}
onClick={() => onSelectProject(project.project_id)}
>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box flex={1}>
<Typography variant="h6" sx={{ mb: 1 }}>
{project.idea.length > 100 ? `${project.idea.substring(0, 100)}...` : project.idea}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip
label={getStepLabel(project.current_step)}
size="small"
color={getStatusColor(project.status)}
sx={{ background: alpha("#667eea", 0.2), color: "#a78bfa" }}
/>
<Typography variant="caption" color="text.secondary">
{project.speakers} {project.speakers === 1 ? "speaker" : "speakers"}
</Typography>
<Typography variant="caption" color="text.secondary">
{project.duration} min
</Typography>
<Typography variant="caption" color="text.secondary">
Updated {new Date(project.updated_at).toLocaleDateString()}
</Typography>
</Stack>
</Box>
<Stack direction="row" spacing={1}>
<Tooltip title={project.is_favorite ? "Remove from favorites" : "Add to favorites"}>
<IconButton
onClick={(e) => {
e.stopPropagation();
handleToggleFavorite(project.project_id, project.is_favorite);
}}
sx={{ color: project.is_favorite ? "#fbbf24" : "rgba(255,255,255,0.5)" }}
>
{project.is_favorite ? <StarIcon /> : <StarBorderIcon />}
</IconButton>
</Tooltip>
<Tooltip title="Delete project">
<IconButton
onClick={(e) => {
e.stopPropagation();
setProjectToDelete(project.project_id);
setDeleteDialogOpen(true);
}}
sx={{ color: "rgba(255,255,255,0.5)" }}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
</Stack>
</Stack>
</GlassyCard>
))}
</Stack>
)}
</Stack>
</Paper>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => {
setDeleteDialogOpen(false);
setProjectToDelete(null);
}}
PaperProps={{
sx: {
background: alpha("#0f172a", 0.95),
backdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.1)",
},
}}
>
<DialogTitle sx={{ color: "white" }}>Delete Project?</DialogTitle>
<DialogContent>
<Typography sx={{ color: "rgba(255,255,255,0.7)" }}>
Are you sure you want to delete this project? This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<SecondaryButton onClick={() => {
setDeleteDialogOpen(false);
setProjectToDelete(null);
}}>
Cancel
</SecondaryButton>
<PrimaryButton onClick={handleDelete} startIcon={<DeleteIcon />}>
Delete
</PrimaryButton>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@@ -0,0 +1,77 @@
import React from "react";
import { Stack, Box, Typography, Paper, Chip, alpha } from "@mui/material";
import { LibraryMusic as LibraryMusicIcon, OpenInNew as OpenInNewIcon, VolumeUp as VolumeUpIcon } from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import { useContentAssets } from "../../hooks/useContentAssets";
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
interface RecentEpisodesPreviewProps {
onSelectEpisode: (assetId: number) => void;
}
export const RecentEpisodesPreview: React.FC<RecentEpisodesPreviewProps> = ({ onSelectEpisode }) => {
const navigate = useNavigate();
const { assets, loading } = useContentAssets({
asset_type: "audio",
source_module: "podcast_maker",
limit: 6,
});
if (loading || assets.length === 0) {
return null;
}
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<LibraryMusicIcon />
Recent Episodes
</Typography>
<SecondaryButton
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
startIcon={<OpenInNewIcon />}
>
View All
</SecondaryButton>
</Stack>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
{assets.slice(0, 6).map((asset) => (
<Paper
key={asset.id}
sx={{
p: 2,
background: alpha("#1e293b", 0.5),
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 2,
cursor: "pointer",
"&:hover": {
borderColor: "rgba(102,126,234,0.4)",
background: alpha("#1e293b", 0.7),
},
}}
onClick={() => onSelectEpisode(asset.id)}
>
<Stack spacing={1}>
<Typography variant="subtitle2" sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{asset.title || "Untitled Episode"}
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<VolumeUpIcon fontSize="small" sx={{ color: "rgba(255,255,255,0.5)" }} />
<Typography variant="caption" color="text.secondary">
{new Date(asset.created_at).toLocaleDateString()}
</Typography>
</Stack>
{asset.cost > 0 && (
<Chip label={`$${asset.cost.toFixed(2)}`} size="small" sx={{ width: "fit-content", fontSize: "0.65rem", height: 20 }} />
)}
</Stack>
</Paper>
))}
</Box>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,552 @@
import React, { useEffect, useState, useRef } from "react";
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, Button, CircularProgress, alpha } from "@mui/material";
import {
PlayArrow as PlayArrowIcon,
ArrowBack as ArrowBackIcon,
VolumeUp as VolumeUpIcon,
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
Info as InfoIcon,
OpenInNew as OpenInNewIcon,
Download as DownloadIcon,
Share as ShareIcon,
Refresh as RefreshIcon,
Videocam as VideocamIcon,
Cancel as CancelIcon,
} from "@mui/icons-material";
import { Script, Knobs, Job, RenderJobResult, TaskStatus } from "./types";
import { podcastApi } from "../../services/podcastApi";
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
import { InlineAudioPlayer } from "./InlineAudioPlayer";
interface RenderQueueProps {
projectId: string;
script: Script;
knobs: Knobs;
jobs: Job[];
budgetCap?: number;
avatarImageUrl?: string | null;
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
onBack: () => void;
onError: (message: string) => void;
}
const getSceneVoiceEmotion = (knobs: Knobs) => knobs.voice_emotion || "neutral";
export const RenderQueue: React.FC<RenderQueueProps> = ({ projectId, script, knobs, jobs, budgetCap, avatarImageUrl, onUpdateJob, onBack, onError }) => {
const [rendering, setRendering] = useState<string | null>(null);
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
const isBusy = Boolean(rendering);
// Cleanup polling intervals on unmount
useEffect(() => {
return () => {
pollingIntervals.current.forEach((interval) => clearInterval(interval));
pollingIntervals.current.clear();
};
}, []);
// Initialize jobs if empty
useEffect(() => {
if (jobs.length === 0 && script.scenes.length > 0) {
const initialJobs: Job[] = script.scenes.map((s) => ({
sceneId: s.id,
title: s.title,
status: "idle" as const,
progress: 0,
previewUrl: null,
finalUrl: null,
jobId: null,
}));
// Update all jobs at once
initialJobs.forEach((job) => {
onUpdateJob(job.sceneId, job);
});
}
}, [script.scenes.length, jobs.length, onUpdateJob]);
const getScene = (sceneId: string) => script.scenes.find((s) => s.id === sceneId);
const pollTaskStatus = async (taskId: string, sceneId: string) => {
try {
const status: TaskStatus = await podcastApi.pollTaskStatus(taskId);
onUpdateJob(sceneId, {
progress: status.progress ?? 0,
status: status.status === "completed" ? "completed" : status.status === "failed" ? "failed" : "running",
});
if (status.status === "completed" && status.result) {
const result = status.result;
const updates: Partial<Job> = {
status: "completed",
progress: 100,
videoUrl: result.video_url,
cost: result.cost,
};
onUpdateJob(sceneId, updates);
// Clear polling interval
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
} else if (status.status === "failed") {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
// Clear polling interval
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
onError(status.error || "Video generation failed");
}
return status.status === "completed" || status.status === "failed";
} catch (error) {
console.error("Error polling task status:", error);
return false;
}
};
const startPolling = (taskId: string, sceneId: string) => {
// Clear any existing interval for this scene
const existingInterval = pollingIntervals.current.get(sceneId);
if (existingInterval) {
clearInterval(existingInterval);
}
// Poll every 3 seconds
const interval = setInterval(async () => {
const isComplete = await pollTaskStatus(taskId, sceneId);
if (isComplete) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
}, 3000);
pollingIntervals.current.set(sceneId, interval);
};
const cancelRender = async (sceneId: string) => {
const job = jobs.find((j) => j.sceneId === sceneId);
if (job?.taskId) {
try {
await podcastApi.cancelTask(job.taskId);
onUpdateJob(sceneId, { status: "cancelled", progress: 0 });
// Clear polling interval
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
} catch (error) {
console.error("Error cancelling task:", error);
onError("Failed to cancel render job");
}
}
};
const runRender = async (sceneId: string, mode: "preview" | "full") => {
// Prevent double-fire while another render is in-flight
if (rendering && rendering !== sceneId) return;
const job = jobs.find((j) => j.sceneId === sceneId);
if (job && job.status !== "idle") return;
const scene = getScene(sceneId);
if (!scene) return;
// Estimate cost (rough estimate: ~$0.05 per 1000 chars)
const textLength = scene.lines.map((l) => l.text).join(" ").length;
const estimatedCost = (textLength / 1000) * 0.05;
// Check budget cap if provided
if (budgetCap && budgetCap > 0) {
const currentSpent = jobs
.filter((j) => j.status === "completed" && j.cost)
.reduce((sum, j) => sum + (j.cost || 0), 0);
if (currentSpent + estimatedCost > budgetCap) {
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(4)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
return;
}
}
setRendering(sceneId);
onUpdateJob(sceneId, {
status: mode === "preview" ? "previewing" : "running",
progress: mode === "preview" ? 25 : 40,
});
try {
const result: RenderJobResult = await podcastApi.renderSceneAudio({
scene,
voiceId: "Wise_Woman",
emotion: getSceneVoiceEmotion(knobs),
speed: knobs.voice_speed,
});
const updates: Partial<Job> = {
status: "completed",
progress: 100,
cost: result.cost,
provider: result.provider,
voiceId: result.voiceId,
fileSize: result.fileSize,
};
if (mode === "preview") {
updates.previewUrl = result.audioUrl;
window.open(result.audioUrl, "_blank");
} else {
updates.finalUrl = result.audioUrl;
// Save to asset library when final render completes
try {
await podcastApi.saveAudioToAssetLibrary({
audioUrl: result.audioUrl,
filename: result.audioFilename,
title: `${script.scenes.find((s) => s.id === sceneId)?.title || "Scene"} - ${projectId}`,
description: `Podcast episode scene audio: ${scene.title}`,
projectId,
sceneId,
cost: result.cost,
provider: result.provider,
model: result.model,
fileSize: result.fileSize,
});
} catch (assetError) {
console.error("Failed to save to asset library:", assetError);
// Don't fail the render if asset save fails
}
}
onUpdateJob(sceneId, updates);
} catch (error) {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
const message = error instanceof Error ? error.message : "Render failed";
onError(message);
} finally {
setRendering(null);
}
};
const runVideoRender = async (sceneId: string) => {
// Prevent double-fire while another render is in-flight
if (rendering && rendering !== sceneId) return;
const scene = getScene(sceneId);
if (!scene) return;
if (!avatarImageUrl) {
onError("Avatar image is required for video generation. Please upload an avatar image in project settings.");
return;
}
const job = jobs.find((j) => j.sceneId === sceneId);
if (!job?.finalUrl) {
onError("Please generate audio first before creating video.");
return;
}
// Estimate cost (video generation is ~$0.30 per 5 seconds at 720p)
const estimatedCost = 0.30; // Base cost per video
// Check budget cap if provided
if (budgetCap && budgetCap > 0) {
const currentSpent = jobs
.filter((j) => j.status === "completed" && j.cost)
.reduce((sum, j) => sum + (j.cost || 0), 0);
if (currentSpent + estimatedCost > budgetCap) {
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(2)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
return;
}
}
setRendering(sceneId);
onUpdateJob(sceneId, {
status: "running",
progress: 5,
});
try {
const result = await podcastApi.generateVideo({
projectId,
sceneId,
sceneTitle: scene.title,
audioUrl: job.finalUrl,
avatarImageUrl: avatarImageUrl,
resolution: knobs.resolution || "720p",
});
// Start polling for video generation status
onUpdateJob(sceneId, {
taskId: result.taskId,
status: "running",
progress: 5,
});
startPolling(result.taskId, sceneId);
} catch (error) {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
const message = error instanceof Error ? error.message : "Video generation failed";
onError(message);
} finally {
setRendering(null);
}
};
const getStatusColor = (status: Job["status"]) => {
switch (status) {
case "completed":
return "success";
case "failed":
return "error";
case "running":
case "previewing":
return "info";
default:
return "default";
}
};
const getStatusIcon = (status: Job["status"]) => {
switch (status) {
case "completed":
return <CheckCircleIcon />;
case "failed":
return <InfoIcon />;
case "running":
case "previewing":
return <CircularProgress size={16} />;
default:
return <RadioButtonUncheckedIcon />;
}
};
return (
<Box sx={{ mt: 3 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
Back to Script
</SecondaryButton>
<Typography
variant="h4"
sx={{
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 800,
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<PlayArrowIcon />
Render Queue
</Typography>
</Stack>
<Alert severity="info" sx={{ mb: 3, background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
<Typography variant="body2">
<strong>Audio Generation:</strong> Preview creates a quick sample to test voice and pacing. Full render generates the complete, production-ready audio file for your episode.
</Typography>
</Alert>
<Stack spacing={2}>
{jobs.map((job) => {
const scene = getScene(job.sceneId);
const initials = job.title
.split(" ")
.slice(0, 2)
.map((s) => s[0])
.join("")
.toUpperCase();
return (
<GlassyCard key={job.sceneId} sx={glassyCardSx}>
<Stack spacing={2}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Paper
sx={{
width: 56,
height: 56,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: alpha("#667eea", 0.2),
border: "1px solid rgba(102,126,234,0.3)",
fontWeight: 700,
fontSize: "1.2rem",
}}
>
{initials}
</Paper>
<Box flex={1}>
<Typography variant="h6" sx={{ mb: 0.5 }}>
{job.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip label={`Scene ${job.sceneId.slice(-4)}`} size="small" variant="outlined" />
{job.cost != null && (
<Chip
label={`$${job.cost.toFixed(2)}`}
size="small"
sx={{ background: alpha("#10b981", 0.2), color: "#6ee7b7" }}
title="Generation cost"
/>
)}
{job.fileSize && (
<Typography variant="caption" color="text.secondary">
{(job.fileSize / 1024).toFixed(1)} KB
</Typography>
)}
</Stack>
{job.finalUrl && (
<Button
size="small"
startIcon={<OpenInNewIcon />}
href={job.finalUrl}
target="_blank"
sx={{ mt: 1, color: "#a78bfa" }}
>
Download Final Audio
</Button>
)}
{job.videoUrl && (
<Button
size="small"
startIcon={<VideocamIcon />}
href={job.videoUrl}
target="_blank"
sx={{ mt: 1, ml: 1, color: "#a78bfa" }}
>
Download Video
</Button>
)}
</Box>
<Chip
icon={getStatusIcon(job.status)}
label={job.status.charAt(0).toUpperCase() + job.status.slice(1)}
color={getStatusColor(job.status)}
size="small"
sx={{
textTransform: "capitalize",
minWidth: 100,
}}
/>
</Stack>
{job.status !== "idle" && job.status !== "completed" && (
<Box>
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Progress
</Typography>
<Typography variant="caption" color="text.secondary">
{job.progress}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={job.progress}
sx={{
height: 8,
borderRadius: 4,
background: alpha("#fff", 0.1),
"& .MuiLinearProgress-bar": {
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
},
}}
/>
</Box>
)}
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
<Stack direction="row" spacing={1} justifyContent="flex-end">
{job.status === "idle" && (
<>
<SecondaryButton
onClick={() => runRender(job.sceneId, "preview")}
disabled={isBusy}
startIcon={<VolumeUpIcon />}
tooltip="Preview a sample to test voice and pacing before generating the full episode"
>
Preview Sample
</SecondaryButton>
<PrimaryButton
onClick={() => runRender(job.sceneId, "full")}
disabled={isBusy}
startIcon={<PlayArrowIcon />}
tooltip="Generate the complete, production-ready audio for this scene"
>
Generate Audio
</PrimaryButton>
</>
)}
{job.status === "completed" && (job.previewUrl || job.finalUrl) && (
<Stack spacing={1} sx={{ width: "100%" }}>
<InlineAudioPlayer audioUrl={job.finalUrl || job.previewUrl || ""} title={job.title} />
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Button
size="small"
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => {
const link = document.createElement("a");
link.href = job.finalUrl || job.previewUrl || "";
link.download = `${job.title.replace(/\s+/g, "-")}.mp3`;
link.click();
}}
>
Download
</Button>
<Button
size="small"
variant="outlined"
startIcon={<ShareIcon />}
onClick={async () => {
if (navigator.share && job.finalUrl) {
try {
await navigator.share({
title: job.title,
text: `Check out this podcast episode: ${job.title}`,
url: job.finalUrl,
});
} catch (err) {
// User cancelled or error
}
} else {
// Fallback: copy to clipboard
await navigator.clipboard.writeText(job.finalUrl || job.previewUrl || "");
alert("Audio URL copied to clipboard!");
}
}}
>
Share
</Button>
</Stack>
</Stack>
)}
{job.status === "failed" && (
<Button variant="outlined" color="warning" onClick={() => runRender(job.sceneId, "full")} startIcon={<RefreshIcon />}>
Retry
</Button>
)}
</Stack>
</Stack>
</GlassyCard>
);
})}
</Stack>
<Box sx={{ mt: 3, display: "flex", justifyContent: "flex-end" }}>
<SecondaryButton onClick={onBack}>Done</SecondaryButton>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,106 @@
import React, { useEffect, useState } from "react";
import { Stack, Box, Typography, TextField, Button, Chip, CircularProgress, alpha } from "@mui/material";
import { VolumeUp as VolumeUpIcon } from "@mui/icons-material";
import { Line } from "../types";
import { GlassyCard, glassyCardSx } from "../ui";
interface LineEditorProps {
line: Line;
onChange: (l: Line) => void;
onPreview: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
}
export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPreview }) => {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(line.text);
const [previewing, setPreviewing] = useState(false);
useEffect(() => setText(line.text), [line.text]);
const handleSave = () => {
onChange({ ...line, text });
setEditing(false);
};
const handlePreview = async () => {
setPreviewing(true);
try {
const res = await onPreview(text);
if (res.audioUrl) {
window.open(res.audioUrl, "_blank");
} else {
alert(res.message);
}
} finally {
setPreviewing(false);
}
};
return (
<GlassyCard
whileHover={{ y: -2 }}
sx={{
...glassyCardSx,
p: 2,
transition: "all 0.2s",
}}
>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box flex={1}>
<Chip label={line.speaker} size="small" sx={{ mb: 1, background: alpha("#667eea", 0.2), color: "#a78bfa" }} />
{editing ? (
<TextField
fullWidth
multiline
rows={3}
value={text}
onChange={(e) => setText(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
},
}}
/>
) : (
<Typography variant="body2" sx={{ lineHeight: 1.7, color: "rgba(255,255,255,0.9)" }}>
{line.text}
</Typography>
)}
{line.usedFactIds && line.usedFactIds.length > 0 && (
<Stack direction="row" spacing={0.5} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary">
Facts:
</Typography>
{line.usedFactIds.map((id) => (
<Chip key={id} label={id} size="small" variant="outlined" sx={{ fontSize: "0.65rem", height: 20 }} />
))}
</Stack>
)}
</Box>
<Stack spacing={1} sx={{ ml: 2 }}>
<Button
size="small"
variant={editing ? "contained" : "outlined"}
onClick={editing ? handleSave : () => setEditing(true)}
sx={{ minWidth: 80 }}
>
{editing ? "Save" : "Edit"}
</Button>
<Button
size="small"
variant="outlined"
startIcon={previewing ? <CircularProgress size={14} /> : <VolumeUpIcon />}
onClick={handlePreview}
disabled={previewing || editing}
sx={{ minWidth: 120 }}
>
Preview TTS
</Button>
</Stack>
</Stack>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,86 @@
import React from "react";
import { Stack, Box, Typography, Divider, Chip, alpha } from "@mui/material";
import {
EditNote as EditNoteIcon,
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
} from "@mui/icons-material";
import { Scene, Line } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { LineEditor } from "./LineEditor";
interface SceneEditorProps {
scene: Scene;
onUpdateScene: (s: Scene) => void;
onApprove: (id: string) => Promise<void>;
onPreviewLine: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
approvingSceneId?: string | null;
}
export const SceneEditor: React.FC<SceneEditorProps> = ({
scene,
onUpdateScene,
onApprove,
onPreviewLine,
approvingSceneId,
}) => {
const updateLine = (updatedLine: Line) => {
const updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) };
onUpdateScene(updated);
};
const approving = approvingSceneId === scene.id;
const handleApprove = async () => {
await onApprove(scene.id);
onUpdateScene({ ...scene, approved: true });
};
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<EditNoteIcon fontSize="small" />
{scene.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Chip
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
label={scene.approved ? "Approved" : "Pending Approval"}
size="small"
color={scene.approved ? "success" : "warning"}
sx={{
background: scene.approved ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
color: scene.approved ? "#6ee7b7" : "#fbbf24",
border: scene.approved ? "1px solid rgba(16,185,129,0.3)" : "1px solid rgba(245,158,11,0.3)",
}}
/>
<Typography variant="caption" color="text.secondary">
Duration: {scene.duration}s
</Typography>
</Stack>
</Box>
<PrimaryButton
onClick={handleApprove}
disabled={scene.approved || approving}
loading={approving}
startIcon={scene.approved ? <CheckCircleIcon /> : undefined}
tooltip={scene.approved ? "Scene is approved and ready for rendering" : "Approve this scene to enable rendering"}
>
{scene.approved ? "Approved" : approving ? "Approving..." : "Approve Scene"}
</PrimaryButton>
</Stack>
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
<Stack spacing={2}>
{scene.lines.map((line) => (
<LineEditor key={line.id} line={line} onChange={updateLine} onPreview={(text) => onPreviewLine(text)} />
))}
</Stack>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,229 @@
import React, { useEffect, useState } from "react";
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha } from "@mui/material";
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon } from "@mui/icons-material";
import { Script, Knobs, Scene } from "../types";
import { BlogResearchResponse } from "../../../services/blogWriterApi";
import { podcastApi } from "../../../services/podcastApi";
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
import { SceneEditor } from "./SceneEditor";
interface ScriptEditorProps {
projectId: string;
idea: string;
research: any; // Research type
rawResearch: BlogResearchResponse | null;
knobs: Knobs;
speakers: number;
durationMinutes: number;
script: Script | null;
onScriptChange: (script: Script) => void;
onBackToResearch: () => void;
onProceedToRendering: (script: Script) => void;
onError: (message: string) => void;
}
export const ScriptEditor: React.FC<ScriptEditorProps> = ({
projectId,
idea,
research,
rawResearch,
knobs,
speakers,
durationMinutes,
script: initialScript,
onScriptChange,
onBackToResearch,
onProceedToRendering,
onError,
}) => {
const [script, setScript] = useState<Script | null>(initialScript);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
// Sync with parent state
useEffect(() => {
if (initialScript) {
setScript(initialScript);
}
}, [initialScript]);
useEffect(() => {
// If script already exists, don't regenerate
if (script) {
return;
}
// Only generate if we have research data
if (!rawResearch) {
return;
}
let mounted = true;
setLoading(true);
setError(null);
podcastApi
.generateScript({
projectId,
idea,
research: rawResearch,
knobs,
speakers,
durationMinutes,
})
.then((res) => {
if (mounted) {
setScript(res);
onScriptChange(res);
setError(null);
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to generate script";
setError(message);
onError(message);
})
.finally(() => mounted && setLoading(false));
return () => {
mounted = false;
};
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes]);
const updateScene = (updated: Scene) => {
if (!script) return;
const updatedScript = { ...script, scenes: script.scenes.map((s) => (s.id === updated.id ? updated : s)) };
setScript(updatedScript);
onScriptChange(updatedScript);
};
const approveScene = async (sceneId: string) => {
try {
setApprovingSceneId(sceneId);
await podcastApi.approveScene({ projectId, sceneId });
const updatedScript = script
? {
...script,
scenes: script.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
}
: null;
if (updatedScript) {
setScript(updatedScript);
onScriptChange(updatedScript);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to approve scene";
setError(message);
onError(message);
throw err;
} finally {
setApprovingSceneId((current) => (current === sceneId ? null : current));
}
};
const allApproved = script && script.scenes.every((s) => s.approved);
const approvedCount = script ? script.scenes.filter((s) => s.approved).length : 0;
const totalScenes = script ? script.scenes.length : 0;
return (
<Box sx={{ mt: 3 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
Back to Research
</SecondaryButton>
<Typography
variant="h4"
sx={{
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 800,
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<EditNoteIcon />
Script Editor
</Typography>
</Stack>
{loading && (
<Alert severity="info" icon={<CircularProgress size={20} />} sx={{ mb: 3 }}>
<Typography variant="body2">Generating script with AI... This may take a moment.</Typography>
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{script && (
<Stack spacing={3}>
<Alert severity="info" sx={{ background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
<Typography variant="body2">
<strong>Approval Required:</strong> Each scene must be approved before rendering. Review and edit lines as needed, then approve each scene.
</Typography>
</Alert>
<Stack spacing={2}>
{script.scenes.map((scene, idx) => (
<GlassyCard
key={scene.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: idx * 0.1 }}
>
<SceneEditor
scene={scene}
onUpdateScene={updateScene}
onApprove={approveScene}
onPreviewLine={(text) => podcastApi.previewLine(text)}
approvingSceneId={approvingSceneId}
/>
</GlassyCard>
))}
</Stack>
<Paper
sx={{
p: 3,
background: alpha("#1e293b", 0.6),
border: allApproved ? "2px solid rgba(16,185,129,0.4)" : "1px solid rgba(255,255,255,0.1)",
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="subtitle1" sx={{ mb: 0.5, display: "flex", alignItems: "center", gap: 1 }}>
<CheckCircleIcon fontSize="small" color={allApproved ? "success" : "disabled"} />
Approval Status
</Typography>
<Typography variant="body2" color="text.secondary">
{approvedCount} of {totalScenes} scenes approved
{!allApproved && " — Approve all scenes to enable rendering"}
</Typography>
{!allApproved && (
<LinearProgress
variant="determinate"
value={(approvedCount / totalScenes) * 100}
sx={{ mt: 1, height: 6, borderRadius: 3 }}
/>
)}
</Box>
<PrimaryButton
onClick={() => script && onProceedToRendering(script)}
disabled={!allApproved}
startIcon={<PlayArrowIcon />}
tooltip={!allApproved ? "Approve all scenes to proceed to rendering" : "Start rendering all approved scenes"}
>
Proceed to Rendering
</PrimaryButton>
</Stack>
</Paper>
</Stack>
)}
</Box>
);
};

View File

@@ -0,0 +1,4 @@
export { LineEditor } from "./LineEditor";
export { SceneEditor } from "./SceneEditor";
export { ScriptEditor } from "./ScriptEditor";

View File

@@ -67,11 +67,14 @@ export type Job = {
progress: number;
previewUrl?: string | null;
finalUrl?: string | null;
videoUrl?: string | null;
jobId?: string | null;
taskId?: string | null;
cost?: number | null;
provider?: string | null;
voiceId?: string | null;
fileSize?: number | null;
avatarImageUrl?: string | null;
};
export type PodcastAnalysis = {
@@ -115,5 +118,18 @@ export type RenderJobResult = {
cost: number;
voiceId: string;
fileSize: number;
videoUrl?: string;
videoFilename?: string;
};
export type TaskStatus = {
task_id: string;
status: "pending" | "processing" | "completed" | "failed";
progress?: number;
message?: string;
result?: any;
error?: string;
created_at?: string;
updated_at?: string;
};

View File

@@ -0,0 +1,14 @@
import React from "react";
import { motion } from "framer-motion";
import { Paper, alpha } from "@mui/material";
export const GlassyCard = motion(Paper);
export const glassyCardSx = {
borderRadius: 2,
border: "1px solid rgba(0,0,0,0.08)",
background: "#ffffff",
p: 2.5,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
};

View File

@@ -0,0 +1,58 @@
import React from "react";
import { Button, CircularProgress, Tooltip, alpha } from "@mui/material";
interface PrimaryButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
startIcon?: React.ReactNode;
tooltip?: string;
ariaLabel?: string;
}
export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
children,
onClick,
disabled = false,
loading = false,
startIcon,
tooltip,
ariaLabel,
}) => {
const button = (
<Button
variant="contained"
onClick={onClick}
disabled={disabled || loading}
startIcon={loading ? <CircularProgress size={16} /> : startIcon}
aria-label={ariaLabel}
sx={{
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
fontWeight: 600,
textTransform: "none",
px: 3,
py: 1,
"&:hover": {
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
},
"&:disabled": {
background: alpha("#9ca3af", 0.3),
color: alpha("#fff", 0.5),
},
}}
>
{children}
</Button>
);
return tooltip ? (
<Tooltip title={tooltip} arrow>
<span>{button}</span>
</Tooltip>
) : (
button
);
};

View File

@@ -0,0 +1,52 @@
import React from "react";
import { Button, Tooltip, alpha } from "@mui/material";
interface SecondaryButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
startIcon?: React.ReactNode;
tooltip?: string;
ariaLabel?: string;
}
export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
children,
onClick,
disabled = false,
startIcon,
tooltip,
ariaLabel,
}) => {
const button = (
<Button
variant="outlined"
onClick={onClick}
disabled={disabled}
startIcon={startIcon}
aria-label={ariaLabel}
sx={{
borderColor: "rgba(255,255,255,0.2)",
color: "rgba(255,255,255,0.9)",
textTransform: "none",
px: 2.5,
py: 0.75,
"&:hover": {
borderColor: "rgba(255,255,255,0.4)",
background: alpha("#fff", 0.05),
},
}}
>
{children}
</Button>
);
return tooltip ? (
<Tooltip title={tooltip} arrow>
<span>{button}</span>
</Tooltip>
) : (
button
);
};

View File

@@ -0,0 +1,4 @@
export { GlassyCard, glassyCardSx } from "./GlassyCard";
export { PrimaryButton } from "./PrimaryButton";
export { SecondaryButton } from "./SecondaryButton";

View File

@@ -477,10 +477,12 @@ export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCa
variant="contained"
onClick={handleCreate}
disabled={
isCreatingBlueprint ||
isGeneratingProposals ||
isValidatingPreflight ||
(preflightResult && !preflightResult.can_proceed)
Boolean(
isCreatingBlueprint ||
isGeneratingProposals ||
isValidatingPreflight ||
(preflightResult ? !preflightResult.can_proceed : false)
)
}
startIcon={
isCreatingBlueprint || isGeneratingProposals ? (

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Box, TextField, Stack, Typography } from '@mui/material';
import { Product as ProductIcon } from '@mui/icons-material';
import { Inventory2 as ProductIcon } from '@mui/icons-material';
interface ProductInfoFormProps {
productName: string;

View File

@@ -0,0 +1,453 @@
/**
* YouTube Creator Studio Component
*
* AI-first YouTube video creation tool with persona integration.
* Three-phase workflow: Plan → Scenes → Render
*/
import React, { useState, useMemo, useCallback } from 'react';
import {
Box,
Container,
Typography,
Stepper,
Step,
StepLabel,
Paper,
Button,
Alert,
} from '@mui/material';
import { ArrowBack } from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { youtubeApi, type VideoPlan, type Scene } from '../../services/youtubeApi';
import { STEPS, YT_RED, YT_BG, YT_BORDER, YT_TEXT, type Resolution, type DurationType } from './constants';
import { PlanStep } from './components/PlanStep';
import { ScenesStep } from './components/ScenesStep';
import { RenderStep } from './components/RenderStep';
import { useRenderPolling } from './hooks/useRenderPolling';
import { useCostEstimate } from './hooks/useCostEstimate';
import HeaderControls from '../shared/HeaderControls';
const YouTubeCreator: React.FC = () => {
const navigate = useNavigate();
const [activeStep, setActiveStep] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// Step 1: Plan
const [userIdea, setUserIdea] = useState('');
const [durationType, setDurationType] = useState<DurationType>('medium');
const [referenceImage, setReferenceImage] = useState('');
const [videoPlan, setVideoPlan] = useState<VideoPlan | null>(null);
// Step 2: Scenes
const [scenes, setScenes] = useState<Scene[]>([]);
const [editingSceneId, setEditingSceneId] = useState<number | null>(null);
const [editedScene, setEditedScene] = useState<Partial<Scene> | null>(null);
// Step 3: Render
const [renderTaskId, setRenderTaskId] = useState<string | null>(null);
const [renderStatus, setRenderStatus] = useState<any>(null);
const [renderProgress, setRenderProgress] = useState(0);
const [resolution, setResolution] = useState<Resolution>('720p');
const [combineScenes, setCombineScenes] = useState(true);
// Custom hooks
const { renderStatus: polledStatus, renderProgress: polledProgress, error: pollingError } = useRenderPolling(
renderTaskId,
() => setSuccess('Video rendered successfully!'),
(err) => setError(err)
);
// Update local state from polling hook
React.useEffect(() => {
if (polledStatus) {
setRenderStatus(polledStatus);
}
if (polledProgress !== undefined) {
setRenderProgress(polledProgress);
}
if (pollingError) {
setError(pollingError);
}
}, [polledStatus, polledProgress, pollingError]);
const { costEstimate, loadingCostEstimate } = useCostEstimate({
activeStep,
scenes,
resolution,
renderTaskId,
});
// Memoized computed values
const enabledScenesCount = useMemo(
() => scenes.filter(s => s.enabled !== false).length,
[scenes]
);
// Handlers
const handleGeneratePlan = useCallback(async () => {
if (!userIdea.trim()) {
setError('Please enter your video idea');
return;
}
setLoading(true);
setError(null);
setSuccess(null);
try {
const response = await youtubeApi.createPlan({
user_idea: userIdea,
duration_type: durationType,
reference_image_description: referenceImage || undefined,
});
if (response.success && response.plan) {
setVideoPlan(response.plan);
setSuccess('Video plan generated successfully!');
setTimeout(() => {
setActiveStep(1);
setSuccess(null);
}, 1000);
} else {
setError(response.message || 'Failed to generate plan');
}
} catch (err: any) {
setError(err.message || 'Failed to generate video plan');
} finally {
setLoading(false);
}
}, [userIdea, durationType, referenceImage]);
const handleBuildScenes = useCallback(async () => {
if (!videoPlan) {
setError('Please generate a plan first');
return;
}
setLoading(true);
setError(null);
setSuccess(null);
try {
const response = await youtubeApi.buildScenes(videoPlan);
if (response.success && response.scenes) {
setScenes(response.scenes.map(s => ({ ...s, enabled: s.enabled !== false })));
setSuccess(`Built ${response.scenes.length} scenes successfully!`);
setTimeout(() => {
setActiveStep(2);
setSuccess(null);
}, 1000);
} else {
setError(response.message || 'Failed to build scenes');
}
} catch (err: any) {
setError(err.message || 'Failed to build scenes');
} finally {
setLoading(false);
}
}, [videoPlan]);
const handleEditScene = useCallback((scene: Scene) => {
setEditingSceneId(scene.scene_number);
setEditedScene({
narration: scene.narration,
visual_prompt: scene.visual_prompt,
duration_estimate: scene.duration_estimate,
enabled: scene.enabled !== false,
});
}, []);
const handleSaveScene = useCallback(async () => {
if (!editingSceneId || !editedScene) return;
setLoading(true);
setError(null);
try {
const response = await youtubeApi.updateScene(editingSceneId, {
narration: editedScene.narration,
visual_description: editedScene.visual_prompt,
duration_estimate: editedScene.duration_estimate,
enabled: editedScene.enabled,
});
if (response.success && response.scene) {
setScenes(scenes.map(s =>
s.scene_number === editingSceneId ? { ...s, ...response.scene } : s
));
setEditingSceneId(null);
setEditedScene(null);
setSuccess('Scene updated successfully!');
} else {
setError(response.message || 'Failed to update scene');
}
} catch (err: any) {
setError(err.message || 'Failed to update scene');
} finally {
setLoading(false);
}
}, [editingSceneId, editedScene, scenes]);
const handleCancelEdit = useCallback(() => {
setEditingSceneId(null);
setEditedScene(null);
}, []);
const handleToggleScene = useCallback((sceneNumber: number) => {
setScenes(scenes.map(s =>
s.scene_number === sceneNumber ? { ...s, enabled: !s.enabled } : s
));
}, [scenes]);
const handleStartRender = useCallback(async () => {
if (scenes.length === 0) {
setError('Please build scenes first');
return;
}
const enabledScenes = scenes.filter(s => s.enabled !== false);
if (enabledScenes.length === 0) {
setError('Please enable at least one scene to render');
return;
}
if (!videoPlan) {
setError('Video plan is missing');
return;
}
setLoading(true);
setError(null);
setSuccess(null);
try {
const response = await youtubeApi.startRender({
scenes: enabledScenes,
video_plan: videoPlan,
resolution,
combine_scenes: combineScenes,
});
if (response.success && response.task_id) {
setRenderTaskId(response.task_id);
setRenderProgress(0);
setSuccess('Video rendering started!');
} else {
setError(response.message || 'Failed to start render');
}
} catch (err: any) {
setError(err.message || 'Failed to start render');
} finally {
setLoading(false);
}
}, [scenes, videoPlan, resolution, combineScenes]);
const getVideoUrl = useCallback(() => {
if (renderStatus?.result?.final_video_url) {
return renderStatus.result.final_video_url;
}
if (renderStatus?.result?.scene_results?.[0]?.video_url) {
return renderStatus.result.scene_results[0].video_url;
}
return null;
}, [renderStatus]);
const handleStepNavigation = useCallback((targetStep: number) => {
if (targetStep === activeStep) return;
// Always allow going back
if (targetStep < activeStep) {
setActiveStep(targetStep);
return;
}
// Forward navigation with guards
if (targetStep === 1) {
if (!videoPlan) {
setError('Please generate a plan first.');
return;
}
setActiveStep(1);
return;
}
if (targetStep === 2) {
if (!videoPlan) {
setError('Please generate a plan first.');
return;
}
if (scenes.length === 0) {
setError('Please build scenes before rendering.');
return;
}
if (enabledScenesCount === 0) {
setError('Enable at least one scene to render.');
return;
}
setActiveStep(2);
return;
}
}, [activeStep, videoPlan, scenes.length, enabledScenesCount]);
const handleResetRender = useCallback(() => {
setRenderTaskId(null);
setRenderStatus(null);
setRenderProgress(0);
setError(null);
}, []);
const handleRetryFailedScenes = useCallback((failedScenes: any[]) => {
if (failedScenes.length > 0) {
const sceneNumbers = failedScenes.map((f: any) => f.scene_number);
const updatedScenes = scenes.map(s =>
sceneNumbers.includes(s.scene_number)
? { ...s, enabled: true }
: s
);
setScenes(updatedScenes);
handleResetRender();
}
}, [scenes, handleResetRender]);
return (
<Container
maxWidth="lg"
sx={{
py: 4,
backgroundColor: YT_BG,
color: YT_TEXT,
minHeight: '100vh',
borderRadius: 2,
border: `1px solid ${YT_BORDER}`,
boxShadow: '0 8px 24px rgba(0,0,0,0.06)',
}}
>
{/* Header */}
<Box sx={{ mb: 4, display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/dashboard')}
variant="outlined"
sx={{ borderColor: YT_BORDER, color: YT_TEXT, backgroundColor: 'white' }}
>
Back to Dashboard
</Button>
<Typography variant="h4" sx={{ flexGrow: 1, fontWeight: 700 }}>
🎥 YouTube Creator Studio
</Typography>
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
</Box>
{/* Stepper */}
<Paper
sx={{
p: 3,
mb: 4,
backgroundColor: 'white',
border: `1px solid ${YT_BORDER}`,
}}
>
<Stepper
activeStep={activeStep}
sx={{
'& .MuiStepIcon-root.Mui-active': { color: YT_RED },
'& .MuiStepIcon-root.Mui-completed': { color: YT_RED },
}}
>
{STEPS.map((label, idx) => (
<Step key={label} completed={idx < activeStep}>
<StepLabel
onClick={() => handleStepNavigation(idx)}
sx={{ cursor: 'pointer', userSelect: 'none' }}
>
{label}
</StepLabel>
</Step>
))}
</Stepper>
</Paper>
{/* Success Alert */}
<AnimatePresence>
{success && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<Alert severity="success" sx={{ mb: 3 }} onClose={() => setSuccess(null)}>
{success}
</Alert>
</motion.div>
)}
</AnimatePresence>
{/* Error Alert */}
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Step Components */}
{activeStep === 0 && (
<PlanStep
userIdea={userIdea}
durationType={durationType}
referenceImage={referenceImage}
loading={loading}
onIdeaChange={setUserIdea}
onDurationChange={setDurationType}
onReferenceImageChange={setReferenceImage}
onGeneratePlan={handleGeneratePlan}
/>
)}
{activeStep === 1 && videoPlan && (
<ScenesStep
videoPlan={videoPlan}
scenes={scenes}
editingSceneId={editingSceneId}
editedScene={editedScene}
loading={loading}
onBuildScenes={handleBuildScenes}
onEditScene={handleEditScene}
onSaveScene={handleSaveScene}
onCancelEdit={handleCancelEdit}
onEditChange={setEditedScene}
onToggleScene={handleToggleScene}
onBack={() => setActiveStep(0)}
onNext={() => setActiveStep(2)}
/>
)}
{activeStep === 2 && (
<RenderStep
renderTaskId={renderTaskId}
renderStatus={renderStatus}
renderProgress={renderProgress}
resolution={resolution}
combineScenes={combineScenes}
enabledScenesCount={enabledScenesCount}
costEstimate={costEstimate}
loadingCostEstimate={loadingCostEstimate}
loading={loading}
onResolutionChange={setResolution}
onCombineScenesChange={setCombineScenes}
onStartRender={handleStartRender}
onBack={() => setActiveStep(1)}
onReset={handleResetRender}
onRetryFailedScenes={handleRetryFailedScenes}
getVideoUrl={getVideoUrl}
/>
)}
</Container>
);
};
export default YouTubeCreator;

View File

@@ -0,0 +1,138 @@
/**
* Plan Details Component
*/
import React from 'react';
import { Paper, Typography, Stack, Box, Grid, Chip } from '@mui/material';
import { VideoPlan } from '../../../services/youtubeApi';
import { YT_BORDER, YT_TEXT } from '../constants';
interface PlanDetailsProps {
plan: VideoPlan;
}
export const PlanDetails: React.FC<PlanDetailsProps> = React.memo(({ plan }) => {
return (
<Paper
elevation={0}
sx={{
mb: 3,
p: 2.5,
border: `1px solid ${YT_BORDER}`,
backgroundColor: '#fff',
borderRadius: 2,
}}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: YT_TEXT }}>
Plan Details
</Typography>
<Stack spacing={1.25}>
{plan.video_summary && (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Summary
</Typography>
<Typography variant="body2" color="text.secondary">
{plan.video_summary}
</Typography>
</Box>
)}
<Grid container spacing={2}>
{plan.target_audience && (
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Target Audience
</Typography>
<Typography variant="body2" color="text.secondary">
{plan.target_audience}
</Typography>
</Grid>
)}
{plan.video_goal && (
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Goal
</Typography>
<Typography variant="body2" color="text.secondary">
{plan.video_goal}
</Typography>
</Grid>
)}
</Grid>
<Grid container spacing={2}>
{plan.key_message && (
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Key Message
</Typography>
<Typography variant="body2" color="text.secondary">
{plan.key_message}
</Typography>
</Grid>
)}
{plan.call_to_action && (
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Call to Action
</Typography>
<Typography variant="body2" color="text.secondary">
{plan.call_to_action}
</Typography>
</Grid>
)}
</Grid>
<Grid container spacing={2}>
{plan.hook_strategy && (
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Hook Strategy
</Typography>
<Typography variant="body2" color="text.secondary">
{plan.hook_strategy}
</Typography>
</Grid>
)}
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Style & Tone
</Typography>
<Typography variant="body2" color="text.secondary">
Visual Style: {plan.visual_style || '—'} | Tone: {plan.tone || '—'}
</Typography>
</Grid>
</Grid>
{plan.seo_keywords && plan.seo_keywords.length > 0 && (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT, mb: 0.5 }}>
SEO Keywords
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{plan.seo_keywords.map((kw, idx) => (
<Chip key={`${kw}-${idx}`} label={kw} size="small" />
))}
</Stack>
</Box>
)}
{plan.content_outline && plan.content_outline.length > 0 && (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT, mb: 0.5 }}>
Content Outline
</Typography>
<Stack spacing={0.75}>
{plan.content_outline.map((item, idx) => (
<Typography key={idx} variant="body2" color="text.secondary">
{item.section || `Section ${idx + 1}`} {item.description || 'Description missing'} ({item.duration_estimate || 0}s)
</Typography>
))}
</Stack>
</Box>
)}
</Stack>
</Paper>
);
});
PlanDetails.displayName = 'PlanDetails';

View File

@@ -0,0 +1,126 @@
/**
* Plan Step Component
*/
import React from 'react';
import {
Paper,
Typography,
TextField,
Button,
Stack,
FormControl,
InputLabel,
Select,
MenuItem,
FormHelperText,
CircularProgress,
} from '@mui/material';
import { PlayArrow } from '@mui/icons-material';
import { motion } from 'framer-motion';
import { inputSx, labelSx, helperSx, selectSx } from '../styles';
import { DurationType } from '../constants';
interface PlanStepProps {
userIdea: string;
durationType: DurationType;
referenceImage: string;
loading: boolean;
onIdeaChange: (idea: string) => void;
onDurationChange: (duration: DurationType) => void;
onReferenceImageChange: (image: string) => void;
onGeneratePlan: () => void;
}
export const PlanStep: React.FC<PlanStepProps> = React.memo(({
userIdea,
durationType,
referenceImage,
loading,
onIdeaChange,
onDurationChange,
onReferenceImageChange,
onGeneratePlan,
}) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Paper
sx={{
p: 4,
backgroundColor: 'white',
border: '1px solid #e5e5e5',
}}
>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
1 Plan Your Video
</Typography>
<Stack spacing={3}>
<TextField
label="What's your video about?"
placeholder="Example: 'AI explains black holes in 60 seconds' or 'Budget travel guide for Tokyo'"
value={userIdea}
onChange={(e) => onIdeaChange(e.target.value)}
multiline
rows={4}
fullWidth
required
helperText="Describe the story in one to two sentences. Include audience, outcome, and hook. Tip: name the platform goal (views, subs, clicks)."
sx={inputSx}
InputLabelProps={{ sx: labelSx }}
FormHelperTextProps={{ sx: helperSx }}
/>
<FormControl fullWidth>
<InputLabel sx={labelSx}>Video Duration</InputLabel>
<Select
value={durationType}
label="Video Duration"
onChange={(e) => onDurationChange(e.target.value as DurationType)}
sx={selectSx}
>
<MenuItem value="shorts">Shorts (15-60 seconds)</MenuItem>
<MenuItem value="medium">Medium (1-4 minutes)</MenuItem>
<MenuItem value="long">Long (4-10 minutes)</MenuItem>
</Select>
<FormHelperText>
Shorts = vertical bite-sized (60s). Medium = quick explainers. Long = deep dives.
</FormHelperText>
</FormControl>
<TextField
label="Reference Image Description (Optional)"
placeholder="Example: 'neon-lit Tokyo alley, rainy night, cinematic bokeh' or paste image keywords"
value={referenceImage}
onChange={(e) => onReferenceImageChange(e.target.value)}
multiline
rows={2}
fullWidth
helperText="Optional: Describe visual cues or style you want the visuals to follow."
sx={inputSx}
InputLabelProps={{ sx: labelSx }}
FormHelperTextProps={{ sx: helperSx }}
/>
<Button
variant="contained"
color="error"
size="large"
onClick={onGeneratePlan}
disabled={loading || !userIdea.trim()}
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
sx={{ alignSelf: 'flex-start', px: 4 }}
>
{loading ? 'Generating Plan...' : 'Generate Video Plan'}
</Button>
</Stack>
</Paper>
</motion.div>
);
});
PlanStep.displayName = 'PlanStep';

View File

@@ -0,0 +1,339 @@
/**
* Render Step Component
*/
import React from 'react';
import {
Paper,
Typography,
Stack,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Switch,
Button,
Box,
Alert,
LinearProgress,
CircularProgress,
Typography as MuiTypography,
} from '@mui/material';
import { PlayArrow, Download, Refresh } from '@mui/icons-material';
import { motion } from 'framer-motion';
import { TaskStatus, CostEstimate } from '../../../services/youtubeApi';
import { YT_BORDER, RESOLUTIONS, type Resolution } from '../constants';
interface RenderStepProps {
renderTaskId: string | null;
renderStatus: TaskStatus | null;
renderProgress: number;
resolution: Resolution;
combineScenes: boolean;
enabledScenesCount: number;
costEstimate: CostEstimate | null;
loadingCostEstimate: boolean;
loading: boolean;
onResolutionChange: (resolution: Resolution) => void;
onCombineScenesChange: (combine: boolean) => void;
onStartRender: () => void;
onBack: () => void;
onReset: () => void;
onRetryFailedScenes: (failedScenes: any[]) => void;
getVideoUrl: () => string | null;
}
export const RenderStep: React.FC<RenderStepProps> = React.memo(({
renderTaskId,
renderStatus,
renderProgress,
resolution,
combineScenes,
enabledScenesCount,
costEstimate,
loadingCostEstimate,
loading,
onResolutionChange,
onCombineScenesChange,
onStartRender,
onBack,
onReset,
onRetryFailedScenes,
getVideoUrl,
}) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Paper
sx={{
p: 4,
backgroundColor: 'white',
border: `1px solid ${YT_BORDER}`,
}}
>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
3 Render Video
</Typography>
{!renderTaskId ? (
<Stack spacing={3}>
<Alert severity="info">
Configure render settings and start generating your video. This may take several minutes.
</Alert>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Video Resolution</InputLabel>
<Select
value={resolution}
label="Video Resolution"
onChange={(e) => onResolutionChange(e.target.value as Resolution)}
>
{RESOLUTIONS.map((res) => (
<MenuItem key={res} value={res}>
{res === '480p' && '480p (Lower cost, faster)'}
{res === '720p' && '720p (Recommended)'}
{res === '1080p' && '1080p (Highest quality)'}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControlLabel
control={
<Switch
checked={combineScenes}
onChange={(e) => onCombineScenesChange(e.target.checked)}
/>
}
label="Combine scenes into single video"
/>
</Grid>
</Grid>
<Box sx={{ p: 2, bgcolor: '#f4f4f4', borderRadius: 1, border: `1px solid ${YT_BORDER}` }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Render Summary
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{enabledScenesCount} scenes will be rendered
<br />
Resolution: {resolution}
<br />
{combineScenes ? 'Scenes will be combined into one video' : 'Each scene will be a separate video'}
<br />
</Typography>
{/* Cost Estimate */}
{loadingCostEstimate ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Calculating cost estimate...
</Typography>
</Box>
) : costEstimate ? (
<Box sx={{ mt: 2, p: 2, bgcolor: 'primary.light', borderRadius: 1, border: '1px solid', borderColor: 'primary.main' }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: 'primary.dark' }}>
💰 Estimated Cost
</Typography>
<Typography variant="h6" sx={{ mb: 1, color: 'primary.dark' }}>
${costEstimate.total_cost.toFixed(2)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Range: ${costEstimate.estimated_cost_range.min.toFixed(2)} - ${costEstimate.estimated_cost_range.max.toFixed(2)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{costEstimate.num_scenes} scenes × ${costEstimate.price_per_second.toFixed(2)}/second
<br />
Total duration: ~{Math.round(costEstimate.total_duration_seconds)} seconds
<br />
Price per second: ${costEstimate.price_per_second.toFixed(2)} ({costEstimate.resolution})
</Typography>
{costEstimate.scene_costs.length > 0 && (
<Box sx={{ mt: 1, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
Per Scene Breakdown:
</Typography>
{costEstimate.scene_costs.slice(0, 5).map((sceneCost) => (
<Typography key={sceneCost.scene_number} variant="caption" color="text.secondary" sx={{ display: 'block' }}>
Scene {sceneCost.scene_number}: {sceneCost.actual_duration}s = ${sceneCost.cost.toFixed(2)}
</Typography>
))}
{costEstimate.scene_costs.length > 5 && (
<Typography variant="caption" color="text.secondary">
... and {costEstimate.scene_costs.length - 5} more scenes
</Typography>
)}
</Box>
)}
</Box>
) : (
<Alert severity="warning" sx={{ mt: 2 }}>
Unable to calculate cost estimate. Please check your scenes and try again.
</Alert>
)}
</Box>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button variant="outlined" onClick={onBack}>
Back to Scenes
</Button>
<Button
variant="contained"
color="error"
size="large"
onClick={onStartRender}
disabled={loading || enabledScenesCount === 0}
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
sx={{ px: 4 }}
>
{loading ? 'Starting Render...' : 'Start Video Render'}
</Button>
</Box>
</Stack>
) : (
<Stack spacing={3}>
{renderStatus && (
<>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary">
{renderStatus.message || 'Processing...'}
</Typography>
<Typography variant="body2" color="text.secondary">
{Math.round(renderProgress)}%
</Typography>
</Box>
<LinearProgress variant="determinate" value={renderProgress} sx={{ height: 8, borderRadius: 1 }} />
</Box>
{renderStatus.status === 'completed' && renderStatus.result && (
<Alert severity="success">
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Video Rendered Successfully!
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
Total cost: ${renderStatus.result.total_cost?.toFixed(2) || '0.00'}
<br />
Scenes rendered: {renderStatus.result.num_scenes || 0}
</Typography>
{getVideoUrl() && (
<Box sx={{ mt: 2 }}>
<video
controls
src={getVideoUrl()!}
style={{ width: '100%', maxHeight: '500px', borderRadius: 8 }}
/>
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
<Button
variant="contained"
startIcon={<Download />}
href={getVideoUrl()!}
download
>
Download Video
</Button>
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={onReset}
>
Render Another
</Button>
</Box>
</Box>
)}
</Alert>
)}
{renderStatus.status === 'failed' && (
<Alert severity="error">
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Render Failed
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
{renderStatus.error || 'An error occurred during rendering'}
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="contained"
size="small"
startIcon={<Refresh />}
onClick={onReset}
>
Retry Render
</Button>
<Button
variant="outlined"
size="small"
onClick={onReset}
>
Start Over
</Button>
</Box>
</Alert>
)}
{renderStatus.status === 'completed' && renderStatus.result?.partial_success && (
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Partial Success
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
{renderStatus.result.num_scenes} scenes rendered successfully, but{' '}
{renderStatus.result.num_failed} scene(s) failed.
{renderStatus.result.failed_scenes && renderStatus.result.failed_scenes.length > 0 && (
<>
<br />
<br />
<strong>Failed Scenes:</strong>
{renderStatus.result.failed_scenes.map((failed: any, idx: number) => (
<Box key={idx} sx={{ mt: 1, p: 1, bgcolor: 'error.light', borderRadius: 1 }}>
<Typography variant="caption">
Scene {failed.scene_number}: {failed.error || 'Unknown error'}
</Typography>
</Box>
))}
</>
)}
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="contained"
size="small"
startIcon={<Refresh />}
onClick={() => {
const failedScenes = renderStatus.result?.failed_scenes || [];
onRetryFailedScenes(failedScenes);
}}
>
Retry Failed Scenes
</Button>
<Button
variant="outlined"
size="small"
onClick={onReset}
>
View Successful Scenes
</Button>
</Box>
</Alert>
)}
</>
)}
</Stack>
)}
</Paper>
</motion.div>
);
});
RenderStep.displayName = 'RenderStep';

View File

@@ -0,0 +1,180 @@
/**
* Scene Card Component
*/
import React from 'react';
import {
Card,
CardContent,
Typography,
Stack,
Chip,
Box,
FormControlLabel,
Switch,
IconButton,
TextField,
Button,
} from '@mui/material';
import { Edit, Check, Close } from '@mui/icons-material';
import { Scene } from '../../../services/youtubeApi';
import { inputSx, labelSx } from '../styles';
interface SceneCardProps {
scene: Scene;
isEditing: boolean;
editedScene: Partial<Scene> | null;
onToggle: (sceneNumber: number) => void;
onEdit: (scene: Scene) => void;
onSave: () => void;
onCancel: () => void;
onEditChange: (updates: Partial<Scene>) => void;
loading: boolean;
}
export const SceneCard: React.FC<SceneCardProps> = React.memo(({
scene,
isEditing,
editedScene,
onToggle,
onEdit,
onSave,
onCancel,
onEditChange,
loading,
}) => {
const sceneData = isEditing && editedScene ? { ...scene, ...editedScene } : scene;
return (
<Card
variant="outlined"
sx={{
opacity: sceneData.enabled === false ? 0.6 : 1,
border: sceneData.enabled === false ? '1px dashed' : '1px solid',
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Scene {scene.scene_number}: {sceneData.title}
</Typography>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
{sceneData.emphasis_tags?.map((tag) => (
<Chip
key={tag}
label={tag}
size="small"
color={
tag === 'hook' ? 'primary' :
tag === 'cta' ? 'secondary' : 'default'
}
/>
))}
<Chip
label={`~${sceneData.duration_estimate}s`}
size="small"
variant="outlined"
/>
</Stack>
</Box>
<Box>
<FormControlLabel
control={
<Switch
checked={sceneData.enabled !== false}
onChange={() => onToggle(scene.scene_number)}
size="small"
/>
}
label="Enable"
sx={{ mr: 1 }}
/>
{!isEditing && (
<IconButton
size="small"
onClick={() => onEdit(scene)}
color="primary"
>
<Edit fontSize="small" />
</IconButton>
)}
</Box>
</Box>
{isEditing ? (
<Stack spacing={2}>
<TextField
label="Narration"
value={sceneData.narration}
onChange={(e) => onEditChange({ narration: e.target.value })}
multiline
rows={3}
fullWidth
sx={inputSx}
InputLabelProps={{ sx: labelSx }}
/>
<TextField
label="Visual Prompt"
value={sceneData.visual_prompt}
onChange={(e) => onEditChange({ visual_prompt: e.target.value })}
multiline
rows={2}
fullWidth
sx={inputSx}
InputLabelProps={{ sx: labelSx }}
/>
<TextField
label="Duration (seconds)"
type="number"
value={sceneData.duration_estimate}
onChange={(e) => onEditChange({ duration_estimate: parseFloat(e.target.value) || 5 })}
inputProps={{ min: 1, max: 10, step: 0.5 }}
fullWidth
sx={inputSx}
InputLabelProps={{ sx: labelSx }}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="contained"
startIcon={<Check />}
onClick={onSave}
disabled={loading}
>
Save
</Button>
<Button
size="small"
variant="outlined"
startIcon={<Close />}
onClick={onCancel}
>
Cancel
</Button>
</Box>
</Stack>
) : (
<>
<Typography variant="body2" sx={{ mb: 1, fontStyle: 'italic', color: 'text.secondary' }}>
"{sceneData.narration}"
</Typography>
<Typography variant="caption" color="text.secondary">
Visual: {sceneData.visual_prompt}
</Typography>
{sceneData.visual_cues && sceneData.visual_cues.length > 0 && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary">
Cues: {sceneData.visual_cues.join(', ')}
</Typography>
</Box>
)}
</>
)}
</CardContent>
</Card>
);
});
SceneCard.displayName = 'SceneCard';

View File

@@ -0,0 +1,140 @@
/**
* Scenes Step Component
*/
import React, { useMemo } from 'react';
import {
Paper,
Typography,
Button,
Stack,
Box,
CircularProgress,
} from '@mui/material';
import { PlayArrow, VideoLibrary } from '@mui/icons-material';
import { motion } from 'framer-motion';
import { VideoPlan, Scene } from '../../../services/youtubeApi';
import { PlanDetails } from './PlanDetails';
import { SceneCard } from './SceneCard';
import { YT_BORDER } from '../constants';
interface ScenesStepProps {
videoPlan: VideoPlan;
scenes: Scene[];
editingSceneId: number | null;
editedScene: Partial<Scene> | null;
loading: boolean;
onBuildScenes: () => void;
onEditScene: (scene: Scene) => void;
onSaveScene: () => void;
onCancelEdit: () => void;
onEditChange: (updates: Partial<Scene>) => void;
onToggleScene: (sceneNumber: number) => void;
onBack: () => void;
onNext: () => void;
}
export const ScenesStep: React.FC<ScenesStepProps> = React.memo(({
videoPlan,
scenes,
editingSceneId,
editedScene,
loading,
onBuildScenes,
onEditScene,
onSaveScene,
onCancelEdit,
onEditChange,
onToggleScene,
onBack,
onNext,
}) => {
const enabledScenesCount = useMemo(
() => scenes.filter(s => s.enabled !== false).length,
[scenes]
);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Paper
sx={{
p: 4,
backgroundColor: 'white',
border: `1px solid ${YT_BORDER}`,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
2 Review & Edit Scenes
</Typography>
{scenes.length === 0 && (
<Button
variant="contained"
color="error"
onClick={onBuildScenes}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
>
{loading ? 'Building Scenes...' : 'Build Scenes from Plan'}
</Button>
)}
</Box>
<PlanDetails plan={videoPlan} />
{scenes.length > 0 ? (
<Stack spacing={2}>
{scenes.map((scene) => (
<SceneCard
key={scene.scene_number}
scene={scene}
isEditing={editingSceneId === scene.scene_number}
editedScene={editedScene}
onToggle={onToggleScene}
onEdit={onEditScene}
onSave={onSaveScene}
onCancel={onCancelEdit}
onEditChange={onEditChange}
loading={loading}
/>
))}
</Stack>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<VideoLibrary sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Click "Build Scenes from Plan" to generate scene-by-scene breakdown
</Typography>
</Box>
)}
{scenes.length > 0 && (
<Box sx={{ mt: 3, display: 'flex', gap: 2, justifyContent: 'space-between' }}>
<Button variant="outlined" onClick={onBack}>
Back to Plan
</Button>
<Box sx={{ display: 'flex', gap: 2 }}>
<Typography variant="body2" sx={{ alignSelf: 'center', color: 'text.secondary' }}>
{enabledScenesCount} of {scenes.length} scenes enabled
</Typography>
<Button
variant="contained"
color="error"
onClick={onNext}
disabled={enabledScenesCount === 0}
>
Proceed to Render ({enabledScenesCount} scenes)
</Button>
</Box>
</Box>
)}
</Paper>
</motion.div>
);
});
ScenesStep.displayName = 'ScenesStep';

View File

@@ -0,0 +1,19 @@
/**
* Constants for YouTube Creator Studio
*/
export const YT_RED = '#FF0000';
export const YT_BG = '#f9f9f9';
export const YT_BORDER = '#e5e5e5';
export const YT_TEXT = '#0f0f0f';
export const STEPS = ['Plan Your Video', 'Review Scenes', 'Render Video'] as const;
export const RESOLUTIONS = ['480p', '720p', '1080p'] as const;
export type Resolution = typeof RESOLUTIONS[number];
export const DURATION_TYPES = ['shorts', 'medium', 'long'] as const;
export type DurationType = typeof DURATION_TYPES[number];
export const POLLING_INTERVAL_MS = 2000; // 2 seconds

View File

@@ -0,0 +1,47 @@
/**
* Custom hook for fetching cost estimates
*/
import { useEffect, useState } from 'react';
import { youtubeApi, type Scene, type CostEstimate } from '../../../services/youtubeApi';
import { type Resolution } from '../constants';
interface UseCostEstimateParams {
activeStep: number;
scenes: Scene[];
resolution: Resolution;
renderTaskId: string | null;
}
export const useCostEstimate = ({ activeStep, scenes, resolution, renderTaskId }: UseCostEstimateParams) => {
const [costEstimate, setCostEstimate] = useState<CostEstimate | null>(null);
const [loadingCostEstimate, setLoadingCostEstimate] = useState(false);
useEffect(() => {
if (activeStep === 2 && scenes.length > 0 && !renderTaskId) {
const fetchCostEstimate = async () => {
setLoadingCostEstimate(true);
try {
const enabledScenes = scenes.filter(s => s.enabled !== false);
const response = await youtubeApi.estimateCost({
scenes: enabledScenes,
resolution: resolution,
});
if (response.success && response.estimate) {
setCostEstimate(response.estimate);
}
} catch (err: any) {
console.error('Error estimating cost:', err);
setCostEstimate(null);
} finally {
setLoadingCostEstimate(false);
}
};
fetchCostEstimate();
}
}, [activeStep, scenes, resolution, renderTaskId]);
return { costEstimate, loadingCostEstimate };
};

View File

@@ -0,0 +1,126 @@
/**
* Custom hook for polling render task status
*/
import { useEffect, useRef, useState } from 'react';
import { youtubeApi, type TaskStatus } from '../../../services/youtubeApi';
import { POLLING_INTERVAL_MS } from '../constants';
interface UseRenderPollingResult {
renderStatus: TaskStatus | null;
renderProgress: number;
error: string | null;
}
export const useRenderPolling = (
renderTaskId: string | null,
onSuccess?: () => void,
onError?: (error: string) => void
): UseRenderPollingResult => {
const [renderStatus, setRenderStatus] = useState<TaskStatus | null>(null);
const [renderProgress, setRenderProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// Clear any existing interval
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (!renderTaskId) {
return;
}
// Start polling
const interval = setInterval(async () => {
try {
const status = await youtubeApi.getRenderStatus(renderTaskId);
setRenderStatus(status);
setRenderProgress(status.progress || 0);
// Stop polling if task is completed or failed
if (status.status === 'completed' || status.status === 'failed') {
console.log(`[YouTubeCreator] Task ${renderTaskId} finished with status: ${status.status}`);
// Clear interval immediately
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (status.status === 'completed') {
onSuccess?.();
} else if (status.status === 'failed') {
// Extract error message from status
const errorMessage = status.error ||
status.message ||
(typeof status.result === 'object' && status.result?.error) ||
'Video rendering failed. Please try again.';
setError(errorMessage);
onError?.(errorMessage);
console.error(`[YouTubeCreator] Render task failed:`, status);
}
return;
}
} catch (err: any) {
console.error('Failed to poll render status:', err);
// Handle 404 - task not found
const is404 = err.response?.status === 404 ||
err.message?.includes('Task not found') ||
err.response?.data?.detail?.error === 'Task not found';
if (is404) {
console.warn(`[YouTubeCreator] Task ${renderTaskId} not found, stopping polling`);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
const errorDetail = err.response?.data?.detail;
const errorMessage = errorDetail?.message ||
errorDetail?.error ||
'Render task not found. This may happen if the server restarted or the task expired. Please try rendering again.';
setError(errorMessage);
onError?.(errorMessage);
return;
}
// For 500 errors (server errors), stop polling
const is500 = err.response?.status === 500;
if (is500) {
console.error(`[YouTubeCreator] Server error while polling, stopping`);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
const errorMessage = 'Server error occurred while checking render status. Please try rendering again.';
setError(errorMessage);
onError?.(errorMessage);
return;
}
// For other errors, continue polling but log them
console.warn(`[YouTubeCreator] Polling error (non-critical), will retry:`, err.message);
}
}, POLLING_INTERVAL_MS);
intervalRef.current = interval;
// Cleanup function
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [renderTaskId, onSuccess, onError]);
return {
renderStatus,
renderProgress,
error,
};
};

View File

@@ -0,0 +1,38 @@
/**
* Shared styles for YouTube Creator Studio
*/
import { YT_RED, YT_TEXT } from './constants';
export const inputSx = {
'& .MuiOutlinedInput-root': {
backgroundColor: '#fff',
color: YT_TEXT,
borderRadius: 1,
'& fieldset': {
borderColor: '#c6c6c6',
},
'&:hover fieldset': {
borderColor: YT_RED,
},
'&.Mui-focused fieldset': {
borderColor: YT_RED,
boxShadow: '0 0 0 2px rgba(255,0,0,0.08)',
},
'& input::placeholder, & textarea::placeholder': {
color: '#5f6368',
opacity: 1,
},
},
};
export const selectSx = {
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#c6c6c6' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: YT_RED },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: YT_RED },
'& .MuiSelect-select': { color: YT_TEXT, backgroundColor: '#fff' },
};
export const labelSx = { color: '#5f6368', '&.Mui-focused': { color: YT_RED } };
export const helperSx = { color: '#5f6368' };

View File

@@ -76,10 +76,12 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
buttonProps = {},
}) => {
const preflightOptions: UsePreflightCheckOptions = {
operation,
enabled: checkOnHover || checkOnMount,
debounceMs: 300,
cacheTtl: 5000,
onBlocked: (response) => {
// Handle blocked response if needed
},
onAllowed: (response) => {
// Handle allowed response if needed
},
};
const {
@@ -88,20 +90,19 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
limitInfo,
loading: preflightLoading,
error: preflightError,
checkOnHover: triggerCheckOnHover,
checkNow: triggerCheckNow,
check: triggerCheck,
} = usePreflightCheck(preflightOptions);
// Check on mount if requested
React.useEffect(() => {
if (checkOnMount) {
triggerCheckNow();
triggerCheck(operation);
}
}, [checkOnMount, triggerCheckNow]);
}, [checkOnMount, triggerCheck, operation]);
// Notify parent of pre-flight result changes
React.useEffect(() => {
if (onPreflightResult) {
if (onPreflightResult && canProceed !== null) {
onPreflightResult(canProceed);
}
}, [canProceed, onPreflightResult]);
@@ -129,7 +130,7 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
// Determine if button should be disabled
const isDisabled = useMemo(() => {
return externalDisabled || externalLoading || preflightLoading || !canProceed;
return externalDisabled || externalLoading || preflightLoading || (canProceed !== null && !canProceed);
}, [externalDisabled, externalLoading, preflightLoading, canProceed]);
// Build tooltip content
@@ -155,7 +156,7 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
content.push(
<Box key="limits" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
{canProceed ? '✅ Operation Allowed' : '❌ Operation Blocked'}
{(canProceed === null || canProceed) ? '✅ Operation Allowed' : '❌ Operation Blocked'}
</Typography>
{isUnlimited ? (
<Typography variant="caption" sx={{ display: 'block' }}>
@@ -189,20 +190,20 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
// Handle hover
const handleMouseEnter = () => {
if (checkOnHover) {
triggerCheckOnHover();
triggerCheck(operation);
}
};
// Handle click
const handleClick = () => {
if (!isDisabled && canProceed) {
if (!isDisabled && (canProceed === null || canProceed)) {
onClick();
}
};
// Determine button color based on state
const buttonColor = useMemo(() => {
if (!canProceed) {
if (canProceed !== null && !canProceed) {
return 'error';
}
return color;
@@ -219,7 +220,7 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
if (showLoading && !externalLoading) {
return 'Checking...';
}
if (!canProceed && preflightError) {
if (canProceed !== null && !canProceed && preflightError) {
return preflightError;
}
return buttonLabel;
@@ -234,7 +235,7 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
startIcon={
showLoading ? (
<CircularProgress size={16} color="inherit" />
) : !canProceed ? (
) : (canProceed !== null && !canProceed) ? (
<WarningIcon fontSize="small" />
) : (
startIcon

View File

@@ -49,11 +49,11 @@ export const toolCategories: ToolCategories = {
},
{
name: 'Podcast Maker',
description: 'Generate research-grounded podcast scripts and audio',
description: 'Create professional podcast episodes with AI-powered research, scriptwriting, and voice narration',
icon: React.createElement(AudioIcon),
status: 'beta',
path: '/podcast-maker',
features: ['Research Workflow', 'Editable Script', 'Scene Approvals', 'WaveSpeed Audio'],
features: ['AI Research', 'Smart Scripting', 'Voice Narration', 'Export & Share', 'Episode Library'],
isHighlighted: true
},
{
@@ -305,12 +305,12 @@ export const toolCategories: ToolCategories = {
features: ['Visual Descriptions', 'Hashtag Strategy', 'Story Content']
},
{
name: 'YouTube Content Writer',
description: 'Video scripts and descriptions',
name: 'YouTube Creator Studio',
description: 'AI-powered YouTube video creation with scenes and rendering',
icon: React.createElement(SocialIcon),
status: 'premium',
path: '/youtube-writer',
features: ['Video Scripts', 'SEO Descriptions', 'Engagement Hooks']
status: 'active',
path: '/youtube-creator',
features: ['Video Planning', 'Scene Generation', 'AI Video Rendering', 'Cost Estimation']
}
]
},

View File

@@ -0,0 +1,71 @@
import { useState, useCallback } from 'react';
interface BudgetTrackingState {
totalSpent: number;
budgetCap: number;
operations: Array<{ id: string; cost: number; timestamp: string; description: string }>;
}
export const useBudgetTracking = (initialBudgetCap: number = 50) => {
const [budget, setBudget] = useState<BudgetTrackingState>({
totalSpent: 0,
budgetCap: initialBudgetCap,
operations: [],
});
const addCost = useCallback((cost: number, description: string) => {
setBudget((prev) => {
const newTotal = prev.totalSpent + cost;
const operation = {
id: `${Date.now()}_${Math.random()}`,
cost,
timestamp: new Date().toISOString(),
description,
};
return {
...prev,
totalSpent: newTotal,
operations: [...prev.operations, operation],
};
});
}, []);
const setBudgetCap = useCallback((cap: number) => {
setBudget((prev) => ({ ...prev, budgetCap: cap }));
}, []);
const reset = useCallback(() => {
setBudget({
totalSpent: 0,
budgetCap: initialBudgetCap,
operations: [],
});
}, [initialBudgetCap]);
const canAfford = useCallback((estimatedCost: number): boolean => {
return budget.totalSpent + estimatedCost <= budget.budgetCap;
}, [budget.totalSpent, budget.budgetCap]);
const getRemaining = useCallback((): number => {
return Math.max(0, budget.budgetCap - budget.totalSpent);
}, [budget.budgetCap, budget.totalSpent]);
const getUsagePercentage = useCallback((): number => {
if (budget.budgetCap === 0) return 0;
return Math.min(100, (budget.totalSpent / budget.budgetCap) * 100);
}, [budget.totalSpent, budget.budgetCap]);
return {
totalSpent: budget.totalSpent,
budgetCap: budget.budgetCap,
remaining: getRemaining(),
usagePercentage: getUsagePercentage(),
operations: budget.operations,
addCost,
setBudgetCap,
canAfford,
reset,
};
};

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useAuth } from '@clerk/clerk-react';
export interface ContentAsset {
@@ -49,40 +49,100 @@ const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:800
export const useContentAssets = (filters: AssetFilters = {}) => {
const { getToken } = useAuth();
const [assets, setAssets] = useState<ContentAsset[]>([]);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
const isFetchingRef = useRef(false);
const abortControllerRef = useRef<AbortController | null>(null);
// Memoize filters to create stable reference - only changes when actual values change
const stableFilters = useMemo(() => {
return {
asset_type: filters.asset_type,
source_module: filters.source_module,
search: filters.search,
tags: filters.tags,
favorites_only: filters.favorites_only,
limit: filters.limit,
offset: filters.offset,
};
}, [
filters.asset_type,
filters.source_module,
filters.search,
filters.tags?.join(','),
filters.favorites_only,
filters.limit,
filters.offset,
]);
// Create stable filter key for comparison
const filterKey = useMemo(() => {
return JSON.stringify(stableFilters);
}, [stableFilters]);
// Store latest filters in ref for use in fetch function
const filtersRef = useRef(stableFilters);
useEffect(() => {
filtersRef.current = stableFilters;
}, [stableFilters]);
// Fetch function - exposed for manual retry, not called automatically on errors
const fetchAssets = useCallback(async () => {
// Prevent concurrent fetches
if (isFetchingRef.current) {
return;
}
// Cancel any pending request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller for this request
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
isFetchingRef.current = true;
setLoading(true);
setError(null);
const token = await getToken();
if (!token) {
throw new Error('Not authenticated');
setLoading(false);
isFetchingRef.current = false;
return;
}
// Use ref to get latest filters
const currentFilters = filtersRef.current;
const params = new URLSearchParams();
if (filters.asset_type) params.append('asset_type', filters.asset_type);
if (filters.source_module) params.append('source_module', filters.source_module);
if (filters.search) params.append('search', filters.search);
if (filters.tags && filters.tags.length > 0) params.append('tags', filters.tags.join(','));
if (filters.favorites_only) params.append('favorites_only', 'true');
params.append('limit', String(filters.limit || 100));
params.append('offset', String(filters.offset || 0));
// Add cache busting for fresh data
params.append('_t', String(Date.now()));
if (currentFilters.asset_type) params.append('asset_type', currentFilters.asset_type);
if (currentFilters.source_module) params.append('source_module', currentFilters.source_module);
if (currentFilters.search) params.append('search', currentFilters.search);
if (currentFilters.tags && currentFilters.tags.length > 0) params.append('tags', currentFilters.tags.join(','));
if (currentFilters.favorites_only) params.append('favorites_only', 'true');
params.append('limit', String(currentFilters.limit || 100));
params.append('offset', String(currentFilters.offset || 0));
const response = await fetch(`${API_BASE_URL}/api/content-assets/?${params.toString()}`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
signal: abortController.signal,
});
if (!response.ok) {
if (response.status === 429) {
setError('Rate limit exceeded. Please try again later.');
setAssets([]);
setTotal(0);
setLoading(false);
isFetchingRef.current = false;
return;
}
throw new Error(`Failed to fetch assets: ${response.statusText}`);
}
@@ -90,16 +150,34 @@ export const useContentAssets = (filters: AssetFilters = {}) => {
setAssets(data.assets);
setTotal(data.total);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch assets');
// Don't set error for aborted requests
if (err instanceof Error && err.name === 'AbortError') {
return;
}
if (err instanceof TypeError && err.message.includes('fetch')) {
setError('Network error. Please check your connection.');
} else {
setError(err instanceof Error ? err.message : 'Failed to fetch assets');
}
setAssets([]);
} finally {
setLoading(false);
isFetchingRef.current = false;
}
}, [getToken, filters]);
}, [getToken]); // Only depend on getToken, use ref for filters
// Fetch on mount and when filters change - but only once per filter change
// NO automatic retry on errors - user must call refetch() manually
useEffect(() => {
fetchAssets();
}, [fetchAssets]);
// Cleanup: abort on unmount or filter change
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [filterKey, fetchAssets]); // Include fetchAssets but it's stable due to ref usage
const toggleFavorite = useCallback(async (assetId: number) => {
try {

View File

@@ -0,0 +1,372 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import {
PodcastAnalysis,
PodcastEstimate,
Query,
Research,
Script,
Knobs,
Job,
CreateProjectPayload,
} from '../components/PodcastMaker/types';
import { BlogResearchResponse, ResearchProvider } from '../services/blogWriterApi';
import { podcastApi } from '../services/podcastApi';
export interface PodcastProjectState {
// Project metadata
project: { id: string; idea: string; duration: number; speakers: number } | null;
// Step results
analysis: PodcastAnalysis | null;
queries: Query[];
selectedQueries: Set<string>;
research: Research | null;
rawResearch: BlogResearchResponse | null;
estimate: PodcastEstimate | null;
scriptData: Script | null;
// Render jobs
renderJobs: Job[];
// Settings
knobs: Knobs;
researchProvider: ResearchProvider;
budgetCap: number;
// UI state
showScriptEditor: boolean;
showRenderQueue: boolean;
// Current step tracking
currentStep: 'create' | 'analysis' | 'research' | 'script' | 'render' | null;
// Timestamps
createdAt?: string;
updatedAt?: string;
}
const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
const DEFAULT_STATE: PodcastProjectState = {
project: null,
analysis: null,
queries: [],
selectedQueries: new Set(),
research: null,
rawResearch: null,
estimate: null,
scriptData: null,
renderJobs: [],
knobs: DEFAULT_KNOBS,
researchProvider: "google",
budgetCap: 50,
showScriptEditor: false,
showRenderQueue: false,
currentStep: null,
};
const STORAGE_KEY = 'podcast_project_state';
export const usePodcastProjectState = () => {
const [state, setState] = useState<PodcastProjectState>(() => {
// Initialize from localStorage if available
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
// Restore Sets from arrays
const restoredState: PodcastProjectState = {
...DEFAULT_STATE,
...parsed,
selectedQueries: parsed.selectedQueries ? new Set(parsed.selectedQueries) : new Set(),
renderJobs: parsed.renderJobs || [],
};
return restoredState;
}
} catch (error) {
console.error('Error loading podcast project state from localStorage:', error);
}
return DEFAULT_STATE;
});
// Debounce ref for database sync
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Persist state to localStorage on every change
useEffect(() => {
try {
// Convert Sets to arrays for JSON serialization
const serializableState = {
...state,
selectedQueries: Array.from(state.selectedQueries),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(serializableState));
} catch (error) {
console.error('Error saving podcast project state to localStorage:', error);
}
}, [state]);
// Sync to database after major steps (debounced)
useEffect(() => {
if (!state.project || !state.project.id) return;
// Capture project ID to avoid closure issues
const projectId = state.project.id;
// Clear existing timeout
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
// Debounce database sync (wait 2 seconds after last change)
syncTimeoutRef.current = setTimeout(async () => {
try {
const dbState = {
analysis: state.analysis,
queries: state.queries,
selected_queries: Array.from(state.selectedQueries),
research: state.research,
raw_research: state.rawResearch,
estimate: state.estimate,
script_data: state.scriptData,
render_jobs: state.renderJobs,
knobs: state.knobs,
research_provider: state.researchProvider,
show_script_editor: state.showScriptEditor,
show_render_queue: state.showRenderQueue,
current_step: state.currentStep,
status: state.currentStep === 'render' && state.renderJobs.every(j => j.status === 'completed') ? 'completed' : 'in_progress',
};
await podcastApi.saveProject(projectId, dbState);
} catch (error) {
console.error('Error syncing project to database:', error);
// Don't throw - localStorage is still working
}
}, 2000);
return () => {
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
};
}, [
state.project,
state.analysis,
state.queries,
state.selectedQueries,
state.research,
state.rawResearch,
state.estimate,
state.scriptData,
state.renderJobs,
state.knobs,
state.researchProvider,
state.showScriptEditor,
state.showRenderQueue,
state.currentStep,
]);
// Setters
const setProject = useCallback((project: PodcastProjectState['project']) => {
setState((prev) => ({ ...prev, project, currentStep: project ? 'analysis' : null, updatedAt: new Date().toISOString() }));
}, []);
const setAnalysis = useCallback((analysis: PodcastProjectState['analysis']) => {
setState((prev) => ({
...prev,
analysis,
currentStep: analysis ? 'research' : prev.currentStep,
updatedAt: new Date().toISOString()
}));
}, []);
const setQueries = useCallback((queries: Query[]) => {
setState((prev) => ({ ...prev, queries, updatedAt: new Date().toISOString() }));
}, []);
const setSelectedQueries = useCallback((selectedQueries: Set<string> | ((prev: Set<string>) => Set<string>)) => {
setState((prev) => {
const newQueries = typeof selectedQueries === 'function' ? selectedQueries(prev.selectedQueries) : selectedQueries;
return { ...prev, selectedQueries: newQueries, updatedAt: new Date().toISOString() };
});
}, []);
const setResearch = useCallback((research: PodcastProjectState['research']) => {
setState((prev) => ({
...prev,
research,
currentStep: research ? 'script' : prev.currentStep,
updatedAt: new Date().toISOString()
}));
}, []);
const setRawResearch = useCallback((rawResearch: PodcastProjectState['rawResearch']) => {
setState((prev) => ({ ...prev, rawResearch, updatedAt: new Date().toISOString() }));
}, []);
const setEstimate = useCallback((estimate: PodcastProjectState['estimate']) => {
setState((prev) => ({ ...prev, estimate, updatedAt: new Date().toISOString() }));
}, []);
const setScriptData = useCallback((scriptData: PodcastProjectState['scriptData']) => {
setState((prev) => ({
...prev,
scriptData,
currentStep: scriptData ? 'render' : prev.currentStep,
updatedAt: new Date().toISOString()
}));
}, []);
const setRenderJobs = useCallback((renderJobs: Job[]) => {
setState((prev) => ({ ...prev, renderJobs, updatedAt: new Date().toISOString() }));
}, []);
const updateRenderJob = useCallback((sceneId: string, updates: Partial<Job>) => {
setState((prev) => ({
...prev,
renderJobs: prev.renderJobs.map((job) =>
job.sceneId === sceneId ? { ...job, ...updates } : job
),
updatedAt: new Date().toISOString(),
}));
}, []);
const setKnobs = useCallback((knobs: Knobs) => {
setState((prev) => ({ ...prev, knobs, updatedAt: new Date().toISOString() }));
}, []);
const setResearchProvider = useCallback((provider: ResearchProvider) => {
setState((prev) => ({ ...prev, researchProvider: provider, updatedAt: new Date().toISOString() }));
}, []);
const setBudgetCap = useCallback((cap: number) => {
setState((prev) => ({ ...prev, budgetCap: cap, updatedAt: new Date().toISOString() }));
}, []);
const setShowScriptEditor = useCallback((show: boolean) => {
setState((prev) => ({ ...prev, showScriptEditor: show, updatedAt: new Date().toISOString() }));
}, []);
const setShowRenderQueue = useCallback((show: boolean) => {
setState((prev) => ({ ...prev, showRenderQueue: show, updatedAt: new Date().toISOString() }));
}, []);
const setCurrentStep = useCallback((step: PodcastProjectState['currentStep']) => {
setState((prev) => ({ ...prev, currentStep: step, updatedAt: new Date().toISOString() }));
}, []);
// Reset state
const resetState = useCallback(() => {
setState(DEFAULT_STATE);
localStorage.removeItem(STORAGE_KEY);
}, []);
// Initialize project from payload
const initializeProject = useCallback(async (payload: CreateProjectPayload, projectId: string) => {
// Create project in database
try {
await podcastApi.createProjectInDb({
project_id: projectId,
idea: payload.ideaOrUrl,
duration: payload.duration,
speakers: payload.speakers,
budget_cap: payload.budgetCap,
});
} catch (error) {
console.error('Error creating project in database:', error);
// Continue anyway - localStorage fallback
}
setState((prev) => ({
...prev,
project: {
id: projectId,
idea: payload.ideaOrUrl,
duration: payload.duration,
speakers: payload.speakers,
},
knobs: payload.knobs,
budgetCap: payload.budgetCap,
currentStep: 'analysis',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
}, []);
// Load project from database
const loadProjectFromDb = useCallback(async (projectId: string) => {
try {
const dbProject = await podcastApi.loadProject(projectId);
// Restore state from database
setState((prev) => ({
...prev,
project: {
id: dbProject.project_id,
idea: dbProject.idea,
duration: dbProject.duration,
speakers: dbProject.speakers,
},
analysis: dbProject.analysis,
queries: dbProject.queries || [],
selectedQueries: new Set(dbProject.selected_queries || []),
research: dbProject.research,
rawResearch: dbProject.raw_research,
estimate: dbProject.estimate,
scriptData: dbProject.script_data,
renderJobs: dbProject.render_jobs || [],
knobs: dbProject.knobs || DEFAULT_KNOBS,
researchProvider: dbProject.research_provider || 'google',
budgetCap: dbProject.budget_cap || 50,
showScriptEditor: dbProject.show_script_editor || false,
showRenderQueue: dbProject.show_render_queue || false,
currentStep: dbProject.current_step || null,
createdAt: dbProject.created_at,
updatedAt: dbProject.updated_at,
}));
} catch (error) {
console.error('Error loading project from database:', error);
throw error;
}
}, []);
return {
// State
...state,
// Setters
setProject,
setAnalysis,
setQueries,
setSelectedQueries,
setResearch,
setRawResearch,
setEstimate,
setScriptData,
setRenderJobs,
updateRenderJob,
setKnobs,
setResearchProvider,
setBudgetCap,
setShowScriptEditor,
setShowRenderQueue,
setCurrentStep,
// Helpers
resetState,
initializeProject,
loadProjectFromDb,
};
};

View File

@@ -1,257 +1,82 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import {
checkPreflight,
PreflightOperation,
PreflightCheckResponse,
PreflightLimitInfo,
} from '../services/billingService';
import { useState, useCallback } from 'react';
import { checkPreflight, PreflightOperation, PreflightCheckResponse } from '../services/billingService';
export interface UsePreflightCheckOptions {
operation: PreflightOperation;
enabled?: boolean; // Whether to perform check on hover
debounceMs?: number; // Debounce delay (default: 300ms)
cacheTtl?: number; // Cache TTL in ms (default: 5000ms)
onBlocked?: (response: PreflightCheckResponse) => void;
onAllowed?: (response: PreflightCheckResponse) => void;
}
export interface UsePreflightCheckResult {
canProceed: boolean;
estimatedCost: number;
limitInfo: PreflightLimitInfo | null;
loading: boolean;
error: string | null;
checkOnHover: () => void;
checkNow: () => void; // Immediate check
reset: () => void;
}
interface CacheEntry {
data: PreflightCheckResponse;
timestamp: number;
}
/**
* React hook for pre-flight checking operations with cost estimation.
*
* Features:
* - Debounced hover checks (300ms default)
* - In-memory caching (5s default TTL)
* - Request cancellation on unmount
*/
export const usePreflightCheck = (
options: UsePreflightCheckOptions
): UsePreflightCheckResult => {
const {
operation,
enabled = true,
debounceMs = 300,
cacheTtl = 5000,
} = options;
const [canProceed, setCanProceed] = useState<boolean>(true);
const [estimatedCost, setEstimatedCost] = useState<number>(0);
const [limitInfo, setLimitInfo] = useState<PreflightLimitInfo | null>(null);
const [loading, setLoading] = useState<boolean>(false);
export const usePreflightCheck = (options?: UsePreflightCheckOptions) => {
const [isChecking, setIsChecking] = useState(false);
const [lastCheck, setLastCheck] = useState<PreflightCheckResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Cache for pre-flight check results
const cacheRef = useRef<Map<string, CacheEntry>>(new Map());
// Debounce timer ref
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// Abort controller for request cancellation
const abortControllerRef = useRef<AbortController | null>(null);
// Generate cache key from operation
const getCacheKey = useCallback(() => {
return JSON.stringify(operation);
}, [operation]);
// Check if cached result is still valid
const getCachedResult = useCallback((): PreflightCheckResponse | null => {
const cacheKey = getCacheKey();
const cached = cacheRef.current.get(cacheKey);
if (cached) {
const age = Date.now() - cached.timestamp;
if (age < cacheTtl) {
return cached.data;
}
// Cache expired, remove it
cacheRef.current.delete(cacheKey);
}
return null;
}, [getCacheKey, cacheTtl]);
// Store result in cache
const setCache = useCallback((data: PreflightCheckResponse) => {
const cacheKey = getCacheKey();
cacheRef.current.set(cacheKey, {
data,
timestamp: Date.now(),
});
}, [getCacheKey]);
// Perform actual pre-flight check
const performCheck = useCallback(async (): Promise<void> => {
if (!enabled) {
return;
}
// Check cache first
const cached = getCachedResult();
if (cached) {
updateState(cached);
return;
}
// Cancel any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
const currentAbortController = abortControllerRef.current;
setLoading(true);
const check = useCallback(async (operation: PreflightOperation): Promise<PreflightCheckResponse> => {
setIsChecking(true);
setError(null);
try {
const response = await checkPreflight(operation);
setLastCheck(response);
// Check if request was cancelled
if (currentAbortController.signal.aborted) {
return;
}
// Cache the result
setCache(response);
// Update state
updateState(response);
} catch (err: any) {
// Check if request was cancelled
if (currentAbortController.signal.aborted) {
return;
}
const errorMessage = err?.message || 'Pre-flight check failed';
setError(errorMessage);
setCanProceed(false);
setEstimatedCost(0);
setLimitInfo(null);
} finally {
if (!currentAbortController.signal.aborted) {
setLoading(false);
}
}
}, [operation, enabled, getCachedResult, setCache]);
// Update state from response
const updateState = useCallback((response: PreflightCheckResponse) => {
setCanProceed(response.can_proceed);
setEstimatedCost(response.estimated_cost);
// Get limit info from first operation (for single operation checks)
const firstOp = response.operations[0];
if (firstOp) {
setLimitInfo(firstOp.limit_info);
if (!response.can_proceed && firstOp.message) {
setError(firstOp.message);
if (!response.can_proceed) {
setError(response.operations[0]?.message || 'Operation blocked by subscription limits');
options?.onBlocked?.(response);
} else {
setError(null);
options?.onAllowed?.(response);
}
} else {
setLimitInfo(null);
return response;
} catch (err: any) {
const errorMessage = err?.response?.data?.detail || err?.message || 'Preflight check failed';
setError(errorMessage);
// Return blocked response on error
const blockedResponse: PreflightCheckResponse = {
can_proceed: false,
estimated_cost: 0,
operations: [{
provider: operation.provider,
operation_type: operation.operation_type,
cost: 0,
allowed: false,
limit_info: null,
message: errorMessage,
}],
total_cost: 0,
usage_summary: null,
cached: false,
};
setLastCheck(blockedResponse);
options?.onBlocked?.(blockedResponse);
return blockedResponse;
} finally {
setIsChecking(false);
}
}, []);
}, [options]);
// Debounced check for hover events
const checkOnHover = useCallback(() => {
if (!enabled) {
return;
}
// Clear existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Check cache first (no debounce for cache hits)
const cached = getCachedResult();
if (cached) {
updateState(cached);
return;
}
// Debounce the actual API call
debounceTimerRef.current = setTimeout(() => {
performCheck();
}, debounceMs);
}, [enabled, debounceMs, getCachedResult, updateState, performCheck]);
// Immediate check (no debounce)
const checkNow = useCallback(() => {
if (!enabled) {
return;
}
// Clear any pending debounced check
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
performCheck();
}, [enabled, performCheck]);
// Reset state
const reset = useCallback(() => {
setCanProceed(true);
setEstimatedCost(0);
setLimitInfo(null);
setLoading(false);
setError(null);
// Clear debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
// Cancel any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
// Clear debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Cancel any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
// Extract useful properties from lastCheck
const estimatedCost = lastCheck?.estimated_cost ?? 0;
const limitInfo = lastCheck?.operations?.[0]?.limit_info ?? null;
return {
canProceed,
check,
isChecking,
lastCheck,
error,
canProceed: lastCheck?.can_proceed ?? null,
estimatedCost,
limitInfo,
loading,
error,
checkOnHover,
checkNow,
reset,
loading: isChecking,
// For backward compatibility with OperationButton
checkOnHover: () => {}, // No-op for now, can be implemented if needed
checkNow: () => check(lastCheck?.operations?.[0] ? {
provider: lastCheck.operations[0].provider,
operation_type: lastCheck.operations[0].operation_type,
} as PreflightOperation : {
provider: 'gemini',
operation_type: 'unknown',
}),
};
};

View File

@@ -23,6 +23,7 @@ import {
} from "../components/PodcastMaker/types";
import { checkPreflight, PreflightOperation } from "./billingService";
import { TaskStatusResponse } from "./blogWriterApi";
import { TaskStatus } from "./storyWriterApi";
type WaitForTaskFn = (taskId: string) => Promise<TaskStatusResponse>;
@@ -44,7 +45,9 @@ const createId = (prefix: string) => {
return `${prefix}_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
};
const deriveSegments = (option?: StorySetupGenerationResponse["options"][0]): string[] => {
type OptionLike = StorySetupGenerationResponse["options"][0] | { plot_elements?: string; premise?: string };
const deriveSegments = (option?: OptionLike): string[] => {
const segments: string[] = [];
if (option?.plot_elements) {
option.plot_elements
@@ -53,7 +56,7 @@ const deriveSegments = (option?: StorySetupGenerationResponse["options"][0]): st
.filter(Boolean)
.forEach((p) => segments.push(p));
}
if (!segments.length && option?.premise) {
if (!segments.length && "premise" in (option || {}) && (option as any)?.premise) {
segments.push("Intro", "Key Takeaways", "Examples", "CTA");
}
return segments.slice(0, 5);
@@ -65,19 +68,21 @@ const estimateCosts = ({
chars,
quality,
avatars,
queryCount = 3,
}: {
minutes: number;
scenes: number;
chars: number;
quality: string;
avatars: number;
queryCount?: number;
}): PodcastEstimate => {
const secs = Math.max(60, minutes * 60);
const ttsCost = (chars / 1000) * 0.05;
const avatarCost = avatars * 0.15;
const videoRate = quality === "hd" ? 0.06 : 0.03;
const videoCost = secs * videoRate;
const researchCost = 0.5;
const researchCost = +(Math.max(1, queryCount) * 0.1).toFixed(2);
const total = +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2);
return {
ttsCost: +ttsCost.toFixed(2),
@@ -89,25 +94,35 @@ const estimateCosts = ({
};
const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string): Query[] => {
const keywords = persona?.suggested_keywords?.length ? persona.suggested_keywords : seed.split(/\s+/).filter(Boolean);
if (!keywords.length) {
return [
{
id: createId("q"),
query: seed || "ai marketing small business",
rationale: "Seed query derived from idea/topic",
needsRecentStats: true,
},
];
const baseIdea = seed || "AI marketing for small businesses";
const personaKeywords = persona?.suggested_keywords?.filter(Boolean) || [];
const angles = persona?.research_angles ?? [];
const generated: Query[] = [];
const addQuery = (q: string, why: string, needsRecent = false) => {
if (!q.trim()) return;
generated.push({
id: createId("q"),
query: q.trim(),
rationale: why,
needsRecentStats: needsRecent,
});
};
if (personaKeywords.length) {
personaKeywords.slice(0, 4).forEach((k, idx) =>
addQuery(k, angles[idx % Math.max(1, angles.length)] || "Persona-aligned query", /202[45]|latest|trend/i.test(k))
);
}
const angles = persona?.research_angles ?? [];
return keywords.slice(0, 6).map((keyword, idx) => ({
id: createId("q"),
query: `${keyword}`.trim(),
rationale: angles[idx % angles.length] || "High-impact persona angle",
needsRecentStats: /202[45]|latest|trend/i.test(keyword),
}));
if (!generated.length) {
addQuery(`How is ${baseIdea} evolving in 2024?`, "Trend + outcome focus", true);
addQuery(`Best practices for ${baseIdea}`, "Actionable guidance", false);
addQuery(`${baseIdea} case studies with ROI`, "Proof and outcomes", true);
addQuery(`${baseIdea} risks and objections`, "Address listener concerns", false);
}
return generated.slice(0, 6);
};
const mapSourcesToFacts = (sources: BlogResearchResponse["sources"]): Fact[] => {
@@ -191,20 +206,40 @@ const ensureScenes = (outline: StorySetupGenerationResponse["options"] | StorySc
return [];
};
const waitForTaskCompletion = async (taskId: string, poll: WaitForTaskFn): Promise<any> => {
const waitForTaskCompletion = async (
taskId: string,
poll: WaitForTaskFn,
onProgress?: (status: { status: string; progress?: number; message?: string }) => void
): Promise<any> => {
let attempts = 0;
while (attempts < 120) {
const status = await poll(taskId);
// Report progress if callback provided
if (onProgress) {
// Extract latest progress message if available
const latestMessage = status.progress_messages && status.progress_messages.length > 0
? status.progress_messages[status.progress_messages.length - 1].message
: undefined;
onProgress({
status: status.status,
progress: undefined, // TaskStatusResponse doesn't have progress field
message: latestMessage,
});
}
if (status.status === "completed") {
return status.result;
}
if (status.status === "failed") {
throw new Error(status.error || "Task failed");
const errorMsg = status.error || "Task failed";
throw new Error(errorMsg);
}
await sleep(2500);
attempts += 1;
}
throw new Error("Task polling timed out");
throw new Error("Task polling timed out after 5 minutes");
};
const ensurePreflight = async (operation: PreflightOperation) => {
@@ -219,27 +254,27 @@ const ensurePreflight = async (operation: PreflightOperation) => {
export const podcastApi = {
async createProject(payload: CreateProjectPayload): Promise<CreateProjectResult> {
const storyIdea = payload.ideaOrUrl || "AI marketing for small businesses";
const setup = await storyWriterApi.generateStorySetup({ story_idea: storyIdea });
const primary = setup.options?.[0];
const suggestedOutlines = [
{
id: "primary",
title: primary?.premise?.slice(0, 60) || "Episode Outline",
segments: deriveSegments(primary),
},
];
// Podcast-specific analysis (not story setup)
const analysisResp = await aiApiClient.post("/api/podcast/analyze", {
idea: storyIdea,
duration: payload.duration,
speakers: payload.speakers,
});
const outlines = (analysisResp.data?.suggested_outlines || []).map((o: any, idx: number) => ({
id: o.id || `outline-${idx + 1}`,
title: o.title || `Outline ${idx + 1}`,
segments: Array.isArray(o.segments) ? o.segments : deriveSegments({ plot_elements: o.segments }),
}));
const analysis: PodcastAnalysis = {
audience: primary?.audience_age_group || "Growth-minded pros",
contentType: primary?.persona || "How-to podcast",
topKeywords: suggestedOutlines[0].segments.slice(0, 3),
suggestedOutlines,
audience: analysisResp.data?.audience || "Growth-minded pros",
contentType: analysisResp.data?.content_type || "Podcast interview",
topKeywords: analysisResp.data?.top_keywords || outlines[0]?.segments?.slice(0, 3) || [],
suggestedOutlines: outlines,
suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs },
titleSuggestions: [
primary?.premise?.slice(0, 80),
`${primary?.persona || "AI Host"} on ${primary?.story_setting || "automation"}`,
].filter(Boolean) as string[],
titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean),
};
const researchConfig = await getResearchConfig().catch(() => null);
@@ -252,6 +287,7 @@ export const podcastApi = {
chars: Math.max(1000, payload.duration * 900),
quality: payload.knobs.bitrate || "standard",
avatars: payload.speakers,
queryCount: queries.length || 3,
});
return {
@@ -267,6 +303,7 @@ export const podcastApi = {
topic: string;
approvedQueries: Query[];
provider?: ResearchProvider;
onProgress?: (message: string) => void;
}): Promise<{ research: Research; raw: BlogResearchResponse }> {
const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean);
if (!keywords.length) {
@@ -291,7 +328,29 @@ export const podcastApi = {
});
const { task_id } = await blogWriterApi.startResearch(researchPayload);
const result = (await waitForTaskCompletion(task_id, blogWriterApi.pollResearchStatus)) as BlogResearchResponse;
let lastProgressMessage = "";
const result = (await waitForTaskCompletion(
task_id,
blogWriterApi.pollResearchStatus,
(status) => {
// Extract latest progress message and notify caller
if (status.message && status.message !== lastProgressMessage) {
lastProgressMessage = status.message;
if (params.onProgress) {
params.onProgress(status.message);
}
} else if (status.status === "running" && !status.message) {
// Provide default status messages if none available
const defaultMessage = params.provider === "exa"
? "Deep research in progress..."
: "Gathering research sources...";
if (params.onProgress && lastProgressMessage !== defaultMessage) {
lastProgressMessage = defaultMessage;
params.onProgress(defaultMessage);
}
}
}
)) as BlogResearchResponse;
const mapped = mapResearchResponse(result);
return { research: mapped, raw: result };
},
@@ -311,28 +370,34 @@ export const podcastApi = {
actual_provider_name: "gemini",
});
const premise =
params.research?.keyword_analysis?.summary ||
params.research?.keyword_analysis?.key_insights?.join(" ") ||
params.idea;
const response = await aiApiClient.post("/api/podcast/script", {
idea: params.idea,
duration_minutes: params.durationMinutes,
speakers: params.speakers,
research: params.research,
});
const storyRequest: StoryGenerationRequest = {
persona: "AI Podcast Host",
story_setting: "Modern marketing studio",
character_input: "Host and guest conversation",
plot_elements: params.research?.suggested_angles?.join(", ") || params.idea,
writing_style: "Conversational",
story_tone: "Informative",
narrative_pov: "first-person",
audience_age_group: "Adults",
content_rating: "G",
ending_preference: "Call to action",
story_length: params.durationMinutes > 15 ? "Long" : "Medium",
};
const outlineResponse = await storyWriterApi.generateOutline(premise, storyRequest);
const storyScenes = ensureScenes(outlineResponse.outline);
const scriptScenes = storyScenes.map((scene) => storySceneToPodcastScene(scene, params.knobs, params.speakers));
const scenes = response.data?.scenes || [];
const scriptScenes: Scene[] = scenes.map((scene: any) => ({
id: scene.id || createId("scene"),
title: scene.title || "Scene",
duration: scene.duration || Math.max(20, params.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target),
lines:
Array.isArray(scene.lines) && scene.lines.length
? scene.lines.map((l: any) => ({
id: createId("line"),
speaker: l.speaker || "Host",
text: l.text || "",
}))
: [
{
id: createId("line"),
speaker: "Host",
text: "Let's dive into today's topic.",
},
],
approved: false,
}));
return { scenes: scriptScenes };
},
@@ -377,8 +442,8 @@ export const podcastApi = {
actual_provider_name: "wavespeed",
});
const response = await storyWriterApi.generateAIAudio({
scene_number: Number(params.scene.id.replace(/\D+/g, "")) || 0,
const response = await aiApiClient.post("/api/podcast/audio", {
scene_id: params.scene.id,
scene_title: params.scene.title,
text,
voice_id: params.voiceId || "Wise_Woman",
@@ -386,18 +451,14 @@ export const podcastApi = {
emotion: params.emotion || "neutral",
});
if (!response.success) {
throw new Error(response.error || "Render failed");
}
return {
audioUrl: response.audio_url,
audioFilename: response.audio_filename,
provider: response.provider,
model: response.model,
cost: response.cost,
voiceId: response.voice_id,
fileSize: response.file_size,
audioUrl: response.data.audio_url,
audioFilename: response.data.audio_filename,
provider: response.data.provider,
model: response.data.model,
cost: response.data.cost,
voiceId: response.data.voice_id,
fileSize: response.data.file_size,
};
},
@@ -409,6 +470,123 @@ export const podcastApi = {
notes: params.notes,
});
},
// Project persistence endpoints
async saveProject(projectId: string, state: any): Promise<void> {
try {
await aiApiClient.put(`/api/podcast/projects/${projectId}`, state);
} catch (error) {
console.error("Failed to save project to database:", error);
// Don't throw - localStorage fallback is acceptable
}
},
async loadProject(projectId: string): Promise<any> {
const response = await aiApiClient.get(`/api/podcast/projects/${projectId}`);
return response.data;
},
async listProjects(params?: {
status?: string;
favorites_only?: boolean;
limit?: number;
offset?: number;
order_by?: "updated_at" | "created_at";
}): Promise<{ projects: any[]; total: number; limit: number; offset: number }> {
const response = await aiApiClient.get("/api/podcast/projects", { params });
return response.data;
},
async createProjectInDb(params: {
project_id: string;
idea: string;
duration: number;
speakers: number;
budget_cap: number;
}): Promise<any> {
const response = await aiApiClient.post("/api/podcast/projects", params);
return response.data;
},
async deleteProject(projectId: string): Promise<void> {
await aiApiClient.delete(`/api/podcast/projects/${projectId}`);
},
async toggleFavorite(projectId: string): Promise<any> {
const response = await aiApiClient.post(`/api/podcast/projects/${projectId}/favorite`);
return response.data;
},
async saveAudioToAssetLibrary(params: {
audioUrl: string;
filename: string;
title: string;
description?: string;
projectId: string;
sceneId?: string;
cost?: number;
provider?: string;
model?: string;
fileSize?: number;
}): Promise<{ assetId: number }> {
const response = await aiApiClient.post("/api/content-assets/", {
asset_type: "audio",
source_module: "podcast_maker",
filename: params.filename,
file_url: params.audioUrl,
title: params.title,
description: params.description || `Podcast episode audio: ${params.title}`,
tags: ["podcast", "audio", params.projectId],
asset_metadata: {
project_id: params.projectId,
scene_id: params.sceneId,
provider: params.provider,
model: params.model,
},
provider: params.provider,
model: params.model,
cost: params.cost || 0,
file_size: params.fileSize,
mime_type: "audio/mpeg",
});
return { assetId: response.data.id };
},
async generateVideo(params: {
projectId: string;
sceneId: string;
sceneTitle: string;
audioUrl: string;
avatarImageUrl?: string;
resolution?: string;
prompt?: string;
}): Promise<{ taskId: string; status: string; message: string }> {
const response = await aiApiClient.post("/api/podcast/render/video", {
project_id: params.projectId,
scene_id: params.sceneId,
scene_title: params.sceneTitle,
audio_url: params.audioUrl,
avatar_image_url: params.avatarImageUrl,
resolution: params.resolution || "720p",
prompt: params.prompt,
});
return response.data;
},
async pollTaskStatus(taskId: string): Promise<TaskStatus> {
const response = await aiApiClient.get(`/api/podcast/task/${taskId}/status`);
return response.data;
},
async cancelTask(taskId: string): Promise<void> {
// Note: Task cancellation may not be fully supported by backend yet
// This is a placeholder for future implementation
try {
await aiApiClient.post(`/api/story/task/${taskId}/cancel`);
} catch (error) {
console.warn("Task cancellation not supported:", error);
}
},
};
export type PodcastApi = typeof podcastApi;

View File

@@ -0,0 +1,189 @@
// YouTube Creator Studio API Client
import { apiClient } from '../api/client';
const API_BASE = '/api/youtube';
export interface VideoPlanRequest {
user_idea: string;
duration_type: 'shorts' | 'medium' | 'long';
reference_image_description?: string;
source_content_id?: string;
source_content_type?: 'blog' | 'story';
}
export interface VideoPlan {
video_summary: string;
target_audience: string;
video_goal?: string;
key_message?: string;
content_outline: Array<{
section: string;
description: string;
duration_estimate: number;
}>;
hook_strategy: string;
call_to_action?: string;
cta_ideas?: string[];
visual_style: string;
tone?: string;
seo_keywords: string[];
duration_type: string;
estimated_duration?: string;
}
export interface Scene {
scene_number: number;
title: string;
narration: string;
visual_prompt: string;
enhanced_visual_prompt?: string;
duration_estimate: number;
visual_cues: string[];
emphasis_tags: string[];
enabled?: boolean;
}
export interface VideoRenderRequest {
scenes: Scene[];
video_plan: VideoPlan;
resolution?: '480p' | '720p' | '1080p';
combine_scenes?: boolean;
voice_id?: string;
}
export interface TaskStatus {
task_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
progress: number;
message?: string;
result?: any;
error?: string;
}
export interface CostEstimateRequest {
scenes: Scene[];
resolution: '480p' | '720p' | '1080p';
}
export interface CostEstimate {
resolution: string;
price_per_second: number;
num_scenes: number;
total_duration_seconds: number;
scene_costs: Array<{
scene_number: number;
duration_estimate: number;
actual_duration: number;
cost: number;
}>;
total_cost: number;
estimated_cost_range: {
min: number;
max: number;
};
}
export interface CostEstimateResponse {
success: boolean;
estimate?: CostEstimate;
message: string;
}
export const youtubeApi = {
/**
* Generate a video plan from user input.
*/
async createPlan(request: VideoPlanRequest): Promise<{ success: boolean; plan?: VideoPlan; message: string }> {
try {
const response = await apiClient.post(`${API_BASE}/plan`, request);
return response.data;
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Failed to create video plan';
throw new Error(errorMessage);
}
},
/**
* Build scenes from a video plan.
*/
async buildScenes(videoPlan: VideoPlan, customScript?: string): Promise<{ success: boolean; scenes?: Scene[]; message: string }> {
try {
const response = await apiClient.post(`${API_BASE}/scenes`, {
video_plan: videoPlan,
custom_script: customScript || undefined,
});
return response.data;
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Failed to build scenes';
throw new Error(errorMessage);
}
},
/**
* Update a single scene.
*/
async updateScene(
sceneId: number,
updates: {
narration?: string;
visual_description?: string;
duration_estimate?: number;
enabled?: boolean;
}
): Promise<{ success: boolean; scene?: Scene; message: string }> {
try {
const response = await apiClient.post(`${API_BASE}/scenes/${sceneId}/update`, updates);
return response.data;
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Failed to update scene';
throw new Error(errorMessage);
}
},
/**
* Start rendering a video.
*/
async startRender(request: VideoRenderRequest): Promise<{ success: boolean; task_id?: string; message: string }> {
try {
const response = await apiClient.post(`${API_BASE}/render`, request);
return response.data;
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Failed to start render';
throw new Error(errorMessage);
}
},
/**
* Get render task status.
*/
async getRenderStatus(taskId: string): Promise<TaskStatus> {
try {
const response = await apiClient.get(`${API_BASE}/render/${taskId}`);
return response.data;
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Failed to get render status';
throw new Error(errorMessage);
}
},
/**
* Estimate the cost of rendering a video before rendering.
*/
async estimateCost(request: CostEstimateRequest): Promise<CostEstimateResponse> {
try {
const response = await apiClient.post(`${API_BASE}/estimate-cost`, request);
return response.data;
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Failed to estimate cost';
throw new Error(errorMessage);
}
},
/**
* Get video URL for a generated video.
*/
getVideoUrl(filename: string): string {
return `${API_BASE}/videos/${filename}`;
},
};