diff --git a/backend/alwrity_utils/router_manager.py b/backend/alwrity_utils/router_manager.py index 6127bef6..e582767b 100644 --- a/backend/alwrity_utils/router_manager.py +++ b/backend/alwrity_utils/router_manager.py @@ -206,6 +206,13 @@ class RouterManager: except Exception as e: logger.warning(f"Persona router not mounted: {e}") + # Video Studio router + try: + from api.video_studio.router import router as video_studio_router + self.include_router_safely(video_studio_router, "video_studio") + except Exception as e: + logger.warning(f"Video Studio router not mounted: {e}") + # Stability AI routers try: from routers.stability import router as stability_router diff --git a/backend/api/assets_serving.py b/backend/api/assets_serving.py new file mode 100644 index 00000000..d1049304 --- /dev/null +++ b/backend/api/assets_serving.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse +import os +from pathlib import Path +from services.database import WORKSPACE_DIR, get_user_db_path + +router = APIRouter(prefix="/api/assets", tags=["Assets Serving"]) + +@router.get("/{user_id}/avatars/{filename}") +async def serve_avatar(user_id: str, filename: str): + """ + Serve avatar images directly. + Public endpoint relying on unguessable filenames. + """ + # Sanitize user_id (simple check to prevent directory traversal) + safe_user_id = "".join(c for c in user_id if c.isalnum() or c in ('-', '_')) + if safe_user_id != user_id: + raise HTTPException(status_code=400, detail="Invalid user ID") + + # Sanitize filename + safe_filename = os.path.basename(filename) + + # Construct path + # workspace/workspace_{user_id}/assets/avatars/{filename} + file_path = Path(WORKSPACE_DIR) / f"workspace_{safe_user_id}" / "assets" / "avatars" / safe_filename + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Asset not found") + + return FileResponse(file_path) + +@router.get("/{user_id}/voice_samples/{filename}") +async def serve_voice_sample(user_id: str, filename: str): + """ + Serve voice sample audio files directly. + """ + # Sanitize user_id + safe_user_id = "".join(c for c in user_id if c.isalnum() or c in ('-', '_')) + if safe_user_id != user_id: + raise HTTPException(status_code=400, detail="Invalid user ID") + + # Sanitize filename + safe_filename = os.path.basename(filename) + + # Construct path + # workspace/workspace_{user_id}/assets/voice_samples/{filename} + file_path = Path(WORKSPACE_DIR) / f"workspace_{safe_user_id}" / "assets" / "voice_samples" / safe_filename + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Asset not found") + + return FileResponse(file_path) diff --git a/backend/api/onboarding_utils/docs/BRAND_AVATAR_API.md b/backend/api/onboarding_utils/docs/BRAND_AVATAR_API.md new file mode 100644 index 00000000..fb8110b3 --- /dev/null +++ b/backend/api/onboarding_utils/docs/BRAND_AVATAR_API.md @@ -0,0 +1,97 @@ +# Brand Avatar API Documentation + +## Overview +The Brand Avatar API provides endpoints for generating, varying, and enhancing brand avatars using WaveSpeed AI. + +**Base URL**: `/api/onboarding/assets` + +## Endpoints + +### 1. Generate Avatar +Generate a new brand avatar from a text prompt. + +- **URL**: `/generate-avatar` +- **Method**: `POST` +- **Body** (`application/json`): + ```json + { + "prompt": "A professional tech entrepreneur, studio lighting", + "style_preset": "Cinematic", + "aspect_ratio": "1:1", + "model": "ideogram-v3-turbo", + "provider": "wavespeed" + } + ``` +- **Response**: + ```json + { + "success": true, + "image_url": "/api/assets/{user_id}/avatars/{filename}.png", + "image_base64": "...", + "asset_id": 123 + } + ``` + +### 2. Create Variation +Create a variation of an existing avatar/image. + +- **URL**: `/create-variation` +- **Method**: `POST` +- **Content-Type**: `multipart/form-data` +- **Form Data**: + - `prompt` (text): Description of the variation (e.g., "same person but smiling") + - `file` (file): The reference image file +- **Response**: + ```json + { + "success": true, + "image_url": "/api/assets/{user_id}/avatars/{filename}.png", + "image_base64": "...", + "asset_id": 124 + } + ``` + +### 3. Enhance Avatar +Upscale and enhance an existing avatar image. + +- **URL**: `/enhance-avatar` +- **Method**: `POST` +- **Content-Type**: `multipart/form-data` +- **Form Data**: + - `file` (file): The image file to enhance +- **Response**: + ```json + { + "success": true, + "image_url": "/api/assets/{user_id}/avatars/{filename}.png", + "image_base64": "...", + "asset_id": 125 + } + ``` + +### 4. Enhance Prompt +Optimize a simple prompt into a detailed, high-quality prompt using WaveSpeed. + +- **URL**: `/enhance-prompt` +- **Method**: `POST` +- **Body**: + ```json + { + "prompt": "man in suit" + } + ``` +- **Response**: + ```json + { + "success": true, + "original_prompt": "man in suit", + "optimized_prompt": "A professional portrait of a man in a tailored navy blue suit, confident expression, studio lighting, 4k resolution..." + } + ``` + +## Providers +- **Default Provider**: `wavespeed` +- **Models**: + - Generation: `ideogram-v3-turbo` (default), `qwen-image` + - Editing/Variation: `qwen-edit-plus` (default) + - Enhancement: `nano-banana-pro-edit-ultra` (4K upscale) diff --git a/backend/api/onboarding_utils/onboarding_completion_service.py b/backend/api/onboarding_utils/onboarding_completion_service.py index fd15e1e7..0aa9ee77 100644 --- a/backend/api/onboarding_utils/onboarding_completion_service.py +++ b/backend/api/onboarding_utils/onboarding_completion_service.py @@ -100,6 +100,8 @@ class OnboardingCompletionService: except Exception as e: logger.warning(f"Failed to schedule website analysis task creation for user {user_id}: {e}") + + # Schedule onboarding full-site SEO audit (non-blocking) ~10 minutes after completion try: from services.database import SessionLocal diff --git a/backend/api/onboarding_utils/step4_asset_routes.py b/backend/api/onboarding_utils/step4_asset_routes.py index cfc454e0..08d584e0 100644 --- a/backend/api/onboarding_utils/step4_asset_routes.py +++ b/backend/api/onboarding_utils/step4_asset_routes.py @@ -10,22 +10,36 @@ from sqlalchemy.orm import Session from pydantic import BaseModel from loguru import logger from .step4_persona_routes import _extract_user_id +from middleware.auth_middleware import get_current_user import base64 import os from pathlib import Path from utils.file_storage import save_file_safely, generate_unique_filename from services.database import get_db, WORKSPACE_DIR from utils.asset_tracker import save_asset_to_library +from models.content_asset_models import ContentAsset, AssetType, AssetSource +from sqlalchemy import desc from services.llm_providers.main_image_generation import ( generate_image_with_provider, enhance_image_prompt, - generate_image_variation + generate_image_variation, + generate_image_enhance ) +from services.llm_providers.main_audio_generation import clone_voice, qwen3_voice_clone, cosyvoice_voice_clone, qwen3_voice_design +import asyncio +import random +import string -router = APIRouter() +router = APIRouter(prefix="/onboarding/assets") # --- Models --- +class VoiceDesignRequest(BaseModel): + user_id: Optional[str] = None + text: str + voice_description: str + language: str = "auto" + class AvatarPromptRequest(BaseModel): user_id: Optional[str] = None prompt: str @@ -34,6 +48,9 @@ class AvatarPromptRequest(BaseModel): negative_prompt: Optional[str] = None num_inference_steps: int = 30 guidance_scale: float = 7.5 + model: Optional[str] = None + rendering_speed: Optional[str] = None + provider: Optional[str] = None class AvatarEnhanceRequest(BaseModel): user_id: Optional[str] = None @@ -47,14 +64,108 @@ class VoiceCloneRequest(BaseModel): # --- Routes --- +@router.get("/latest-avatar") +async def get_latest_avatar( + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get the latest generated brand avatar for the user.""" + try: + user_id = _extract_user_id(current_user) + + # Search for assets that are either: + # 1. Saved with source_module=BRAND_AVATAR_GENERATOR (new) + # 2. Saved with source_module=STORY_WRITER but have metadata category='brand_avatar' (legacy) + + # Fetch candidates (limit to recent 20 to avoid performance issues) + candidates = db.query(ContentAsset).filter( + ContentAsset.user_id == user_id, + ContentAsset.asset_type == AssetType.IMAGE, + ContentAsset.source_module.in_([ + AssetSource.BRAND_AVATAR_GENERATOR, + AssetSource.STORY_WRITER + ]) + ).order_by(desc(ContentAsset.created_at)).limit(50).all() + + asset = None + for candidate in candidates: + # Check for direct match (new assets) + if candidate.source_module == AssetSource.BRAND_AVATAR_GENERATOR: + asset = candidate + break + + # Check for legacy match (metadata category) + if candidate.source_module == AssetSource.STORY_WRITER: + meta = candidate.asset_metadata or {} + if meta.get('category') == 'brand_avatar': + asset = candidate + break + + if not asset: + return {"success": False, "message": "No avatar found"} + + # Fallback to metadata prompt if main column is empty (legacy support) + prompt = asset.prompt + if not prompt and asset.asset_metadata: + prompt = asset.asset_metadata.get('prompt', '') + + return { + "success": True, + "image_url": asset.file_url, + "prompt": prompt, + "asset_id": asset.id, + "provider": asset.provider + } + except Exception as e: + logger.error(f"Failed to fetch latest avatar: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/latest-voice-clone") +async def get_latest_voice_clone( + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get the latest generated voice clone for the user.""" + try: + user_id = _extract_user_id(current_user) + + # Fetch latest voice clone asset + asset = db.query(ContentAsset).filter( + ContentAsset.user_id == user_id, + ContentAsset.asset_type == AssetType.AUDIO, + ContentAsset.source_module == AssetSource.VOICE_CLONER + ).order_by(desc(ContentAsset.created_at)).first() + + if not asset: + # Try to find legacy assets or assets that might have been saved differently + # For example, voice designs might be saved as VOICE_CLONER too? + # Or check for 'voice_design' logic if needed, but 'voice_cloner' is primary + return {"success": False, "message": "No voice clone found"} + + meta = asset.asset_metadata or {} + + return { + "success": True, + "custom_voice_id": meta.get("custom_voice_id"), + "preview_audio_url": meta.get("preview_url") or asset.file_url, + "asset_id": asset.id, + "voice_name": meta.get("voice_name"), + "engine": meta.get("engine") + } + except Exception as e: + logger.error(f"Failed to fetch latest voice clone: {e}") + raise HTTPException(status_code=500, detail=str(e)) + @router.post("/generate-avatar") async def generate_avatar( request: AvatarPromptRequest, + current_user: Dict[str, Any] = Depends(get_current_user), db: Session = Depends(get_db) ): """Generate a brand avatar using available image providers.""" try: - user_id = _extract_user_id(request.user_id) + user_id = _extract_user_id(current_user) logger.info(f"Generating avatar for user {user_id} with prompt: {request.prompt}") @@ -66,6 +177,9 @@ async def generate_avatar( num_inference_steps=request.num_inference_steps, guidance_scale=request.guidance_scale, style_preset=request.style_preset, + model=request.model, + rendering_speed=request.rendering_speed, + provider=request.provider, user_id=user_id ) @@ -78,42 +192,66 @@ async def generate_avatar( image_data = result.get("image_base64") if not image_data and result.get("image_url"): - # TODO: Download image from URL if needed, or just store URL - pass + try: + import httpx + async with httpx.AsyncClient() as client: + response = await client.get(result["image_url"], timeout=30.0) + response.raise_for_status() + image_data = response.content + except ImportError: + # Fallback to requests if httpx is not installed + import requests + response = requests.get(result["image_url"], timeout=30.0) + response.raise_for_status() + image_data = response.content + except Exception as e: + logger.error(f"Failed to download image from URL: {e}") + raise HTTPException(status_code=500, detail=f"Failed to download generated image: {str(e)}") if image_data: # Decode if needed (usually it's already base64 string) # Save file filename = generate_unique_filename("avatar", "png") - file_path = save_file_safely( - base64.b64decode(image_data) if isinstance(image_data, str) else image_data, - user_id, - "avatars", + # If image_data is bytes (from URL download), pass it directly + # If it's base64 string (from API), decode it + content_to_save = base64.b64decode(image_data) if isinstance(image_data, str) else image_data + + # Construct user assets directory + user_assets_dir = Path(WORKSPACE_DIR) / f"workspace_{user_id}" / "assets" / "avatars" + + saved_path, error = save_file_safely( + content_to_save, + user_assets_dir, filename ) + if error or not saved_path: + raise HTTPException(status_code=500, detail=f"Failed to save image file: {error}") + + # Construct public URL + image_url = f"/api/assets/{user_id}/avatars/{filename}" + # Save to Asset Library asset_id = save_asset_to_library( db=db, user_id=user_id, - file_path=file_path, asset_type="image", - category="brand_avatar", - meta_data={ - "prompt": request.prompt, + source_module="brand_avatar_generator", + filename=filename, + file_url=image_url, + file_path=str(saved_path), + prompt=request.prompt, + asset_metadata={ "provider": result.get("provider", "unknown"), - "style": request.style_preset + "style": request.style_preset, + "category": "brand_avatar" } ) - # Construct public URL (this depends on your static file serving setup) - # Assuming /api/assets/{user_id}/avatars/{filename} - image_url = f"/api/assets/{user_id}/avatars/{filename}" - return { "success": True, "image_url": image_url, - "image_base64": image_data, # Optional: return base64 for immediate display + "image_base64": image_data if isinstance(image_data, str) else base64.b64encode(image_data).decode('utf-8'), "asset_id": asset_id } @@ -126,14 +264,15 @@ async def generate_avatar( @router.post("/enhance-prompt") async def enhance_prompt_route( - request: AvatarEnhanceRequest + request: AvatarEnhanceRequest, + current_user: Dict[str, Any] = Depends(get_current_user) ): """Enhance a simple prompt into a detailed midjourney-style prompt.""" try: - user_id = _extract_user_id(request.user_id) + user_id = _extract_user_id(current_user) logger.info(f"Enhancing prompt for user {user_id}: {request.prompt}") - enhanced_prompt = await enhance_image_prompt(request.prompt) + enhanced_prompt = await enhance_image_prompt(request.prompt, user_id=user_id) return { "success": True, @@ -145,52 +284,347 @@ async def enhance_prompt_route( raise HTTPException(status_code=500, detail=str(e)) +@router.post("/create-variation") +async def create_variation_route( + prompt: str = Form(...), + file: UploadFile = File(...), + user_id: Optional[str] = Form(None), # Ignored in favor of authenticated user + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Generate a variation of an existing avatar.""" + try: + user_id = _extract_user_id(current_user) + logger.info(f"Creating variation for user {user_id} with prompt: {prompt}") + + # Read file + file_content = await file.read() + + result = await generate_image_variation( + image=file_content, + prompt=prompt, + user_id=user_id + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Variation generation failed")) + + # Save result + image_data = result.get("image_base64") + if image_data: + filename = generate_unique_filename("avatar_variation", "png") + content_to_save = base64.b64decode(image_data) + + # Construct user assets directory + user_assets_dir = Path(WORKSPACE_DIR) / f"workspace_{user_id}" / "assets" / "avatars" + + saved_path, error = save_file_safely( + content_to_save, + user_assets_dir, + filename + ) + + if error or not saved_path: + raise HTTPException(status_code=500, detail=f"Failed to save variation file: {error}") + + # Construct public URL + image_url = f"/api/assets/{user_id}/avatars/{filename}" + + # Save to Asset Library + asset_id = save_asset_to_library( + db=next(get_db()), + user_id=user_id, + asset_type="image", + source_module="brand_avatar_variation", + filename=filename, + file_url=image_url, + file_path=str(saved_path), + asset_metadata={ + "prompt": prompt, + "provider": "wavespeed", + "original_filename": file.filename, + "category": "brand_avatar_variation" + } + ) + + return { + "success": True, + "image_url": image_url, + "image_base64": image_data, + "asset_id": asset_id + } + + return {"success": False, "error": "No image data returned"} + + except Exception as e: + logger.error(f"Variation generation failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/enhance-avatar") +async def enhance_avatar_route( + file: UploadFile = File(...), + user_id: Optional[str] = Form(None), # Ignored in favor of authenticated user + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Enhance/Upscale an existing avatar.""" + try: + user_id = _extract_user_id(current_user) + logger.info(f"Enhancing avatar for user {user_id}") + + # Read file + file_content = await file.read() + + result = await generate_image_enhance( + image=file_content, + user_id=user_id + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Enhancement failed")) + + # Save result + image_data = result.get("image_base64") + if image_data: + filename = generate_unique_filename("avatar_enhanced", "png") + content_to_save = base64.b64decode(image_data) + + # Construct user assets directory + user_assets_dir = Path(WORKSPACE_DIR) / f"workspace_{user_id}" / "assets" / "avatars" + + saved_path, error = save_file_safely( + content_to_save, + user_assets_dir, + filename + ) + + if error or not saved_path: + raise HTTPException(status_code=500, detail=f"Failed to save enhanced file: {error}") + + # Construct public URL + image_url = f"/api/assets/{user_id}/avatars/{filename}" + + # Save to Asset Library + asset_id = save_asset_to_library( + db=next(get_db()), + user_id=user_id, + asset_type="image", + source_module="brand_avatar_enhancer", + filename=filename, + file_url=image_url, + file_path=str(saved_path), + asset_metadata={ + "provider": "wavespeed", + "category": "brand_avatar_enhanced", + "original_filename": file.filename + } + ) + + return { + "success": True, + "image_url": image_url, + "image_base64": image_data, + "asset_id": asset_id + } + + return {"success": False, "error": "No image data returned"} + + except Exception as e: + logger.error(f"Avatar enhancement failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/create-voice-clone") async def create_voice_clone( voice_name: str = Form(...), description: str = Form(None), engine: str = Form("qwen3"), file: UploadFile = File(...), - user_id: Optional[str] = Form(None), + user_id: Optional[str] = Form(None), # Ignored in favor of authenticated user + current_user: Dict[str, Any] = Depends(get_current_user), db: Session = Depends(get_db) ): """Create a voice clone from an audio file.""" try: - user_id = _extract_user_id(user_id) - logger.info(f"Creating voice clone '{voice_name}' for user {user_id}") + user_id = _extract_user_id(current_user) + logger.info(f"Creating voice clone '{voice_name}' (engine={engine}) for user {user_id}") # 1. Save uploaded audio file file_content = await file.read() filename = generate_unique_filename("voice_sample", Path(file.filename).suffix.lstrip(".")) - file_path = save_file_safely(file_content, user_id, "voice_samples", filename) - # 2. Call Voice Cloning API (Placeholder for actual implementation) - # TODO: Integrate with Minimax or CosyVoice API - # For now, we simulate success + user_voice_dir = Path(WORKSPACE_DIR) / f"workspace_{user_id}" / "assets" / "voice_samples" + saved_path, error = save_file_safely(file_content, user_voice_dir, filename) - # 3. Save to Asset Library + if error or not saved_path: + raise HTTPException(status_code=500, detail=f"Failed to save voice sample: {error}") + + file_path = str(saved_path) + + # 2. Call Voice Cloning API + preview_audio_bytes = None + custom_voice_id = None + + loop = asyncio.get_event_loop() + + # Default preview text + preview_text = "Hello! This is a preview of my cloned voice using AI technology. I hope you like it!" + + if engine.lower() == "minimax": + # Generate valid voice ID for Minimax (alphanumeric, starts with letter, 8+ chars) + random_suffix = ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + custom_voice_id = f"vc_{random_suffix}" + + logger.info(f"Cloning voice with Minimax, ID: {custom_voice_id}") + + # Run blocking call in executor + result = await loop.run_in_executor( + None, + lambda: clone_voice( + audio_bytes=file_content, + custom_voice_id=custom_voice_id, + text=preview_text, + user_id=user_id + ) + ) + preview_audio_bytes = result.preview_audio_bytes + + elif engine.lower() == "cosyvoice": + logger.info("Cloning voice with CosyVoice") + result = await loop.run_in_executor( + None, + lambda: cosyvoice_voice_clone( + audio_bytes=file_content, + text=preview_text, + user_id=user_id + ) + ) + preview_audio_bytes = result.preview_audio_bytes + # CosyVoice doesn't persist ID on provider side, but we need one for DB + asset_uuid = ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + custom_voice_id = f"vc_cosy_{asset_uuid}" + + else: # qwen3 (default) + logger.info("Cloning voice with Qwen3") + result = await loop.run_in_executor( + None, + lambda: qwen3_voice_clone( + audio_bytes=file_content, + text=preview_text, + user_id=user_id + ) + ) + preview_audio_bytes = result.preview_audio_bytes + # Qwen3 doesn't persist ID on provider side + asset_uuid = ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + custom_voice_id = f"vc_qwen_{asset_uuid}" + + # 3. Save Preview Audio (if generated) + preview_url = None + if preview_audio_bytes: + preview_filename = f"preview_{filename}" + # Ensure it ends with .wav + if not preview_filename.endswith(".wav"): + preview_filename = str(Path(preview_filename).with_suffix('.wav')) + + user_voice_dir = Path(WORKSPACE_DIR) / f"workspace_{user_id}" / "assets" / "voice_samples" + saved_preview_path, error = save_file_safely(preview_audio_bytes, user_voice_dir, preview_filename) + + if not error and saved_preview_path: + preview_url = f"/api/assets/{user_id}/voice_samples/{preview_filename}" + + # 4. Save to Asset Library asset_id = save_asset_to_library( db=db, user_id=user_id, file_path=file_path, asset_type="audio", - category="voice_clone", - meta_data={ + source_module="voice_cloner", + filename=filename, + file_url=f"/api/assets/{user_id}/voice_samples/{filename}", + asset_metadata={ "voice_name": voice_name, "engine": engine, "description": description, - "original_filename": file.filename + "original_filename": file.filename, + "custom_voice_id": custom_voice_id, + "preview_url": preview_url, + "category": "voice_clone" } ) return { "success": True, - "custom_voice_id": f"vc_{asset_id}", # Mock ID - "preview_audio_url": f"/api/assets/{user_id}/voice_samples/{filename}", + "custom_voice_id": custom_voice_id, + "preview_audio_url": preview_url or f"/api/assets/{user_id}/voice_samples/{filename}", "asset_id": asset_id, - "message": "Voice clone created successfully (simulated)" + "message": "Voice clone created successfully" } except Exception as e: logger.error(f"Voice cloning failed: {e}") raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/create-voice-design") +async def create_voice_design( + request: VoiceDesignRequest, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Create a voice from text description (Voice Design).""" + try: + user_id = _extract_user_id(current_user) + logger.info(f"Designing voice for user {user_id}") + + loop = asyncio.get_event_loop() + + result = await loop.run_in_executor( + None, + lambda: qwen3_voice_design( + text=request.text, + voice_description=request.voice_description, + language=request.language, + user_id=user_id + ) + ) + + # Save the result to a temporary file + filename = generate_unique_filename("voice_design_preview", "wav") + user_voice_dir = Path(WORKSPACE_DIR) / f"workspace_{user_id}" / "assets" / "voice_samples" + saved_path, error = save_file_safely(result.preview_audio_bytes, user_voice_dir, filename) + + if error or not saved_path: + raise HTTPException(status_code=500, detail=f"Failed to save voice design: {error}") + + # Generate URL + preview_url = f"/api/assets/{user_id}/voice_samples/{filename}" + + # Save to Asset Library + asset_id = save_asset_to_library( + db=db, + user_id=user_id, + file_path=str(saved_path), + asset_type="audio", + source_module="voice_cloner", + filename=filename, + file_url=preview_url, + asset_metadata={ + "voice_description": request.voice_description, + "text": request.text, + "language": request.language, + "engine": "qwen3-design", + "category": "voice_design", + "preview_url": preview_url + } + ) + + return { + "success": True, + "preview_audio_url": preview_url, + "asset_id": asset_id, + "message": "Voice generated successfully" + } + + except Exception as e: + logger.error(f"Voice design failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/api/podcast/handlers/audio.py b/backend/api/podcast/handlers/audio.py index 40955fdb..c6518a9f 100644 --- a/backend/api/podcast/handlers/audio.py +++ b/backend/api/podcast/handlers/audio.py @@ -4,10 +4,10 @@ Podcast Audio Handlers Audio generation, combining, and serving endpoints. """ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi.responses import FileResponse from sqlalchemy.orm import Session -from typing import Dict, Any +from typing import Dict, Any, Optional from pathlib import Path from urllib.parse import urlparse import tempfile @@ -31,6 +31,83 @@ from ..models import ( router = APIRouter() +@router.post("/audio/upload") +async def upload_podcast_audio( + file: UploadFile = File(...), + project_id: Optional[str] = Form(None), + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Upload an audio file (voice sample) for a podcast project. + Returns the audio URL for use in video generation. + """ + user_id = require_authenticated_user(current_user) + + # Validate file type + if not file.content_type or not file.content_type.startswith('audio/'): + # Allow octet-stream if extension is audio + allowed_exts = ['.mp3', '.wav', '.m4a', '.aac'] + file_ext = Path(file.filename).suffix.lower() + if file_ext not in allowed_exts and file.content_type != 'application/octet-stream': + raise HTTPException(status_code=400, detail="File must be an audio file") + + # Validate file size (max 20MB) + file_content = await file.read() + if len(file_content) > 20 * 1024 * 1024: + raise HTTPException(status_code=400, detail="Audio file size must be less than 20MB") + + try: + # Generate filename + file_ext = Path(file.filename).suffix or '.mp3' + unique_id = str(uuid.uuid4())[:8] + audio_filename = f"audio_{project_id or 'temp'}_{unique_id}{file_ext}" + audio_path = PODCAST_AUDIO_DIR / audio_filename + + # Save file + with open(audio_path, "wb") as f: + f.write(file_content) + + logger.info(f"[Podcast] Audio uploaded: {audio_path}") + + # Create audio URL + audio_url = f"/api/podcast/audio/{audio_filename}" + + # Save to asset library if project_id provided + if project_id: + try: + save_asset_to_library( + db=db, + user_id=user_id, + asset_type="audio", + source_module="podcast_maker", + filename=audio_filename, + file_url=audio_url, + file_path=str(audio_path), + file_size=len(file_content), + mime_type=file.content_type, + title=f"Uploaded Audio - {project_id}", + description="Uploaded podcast audio/voice sample", + tags=["podcast", "audio", "upload", project_id], + asset_metadata={ + "project_id": project_id, + "type": "uploaded_audio", + "status": "completed", + }, + ) + except Exception as e: + logger.warning(f"[Podcast] Failed to save audio asset: {e}") + + return { + "audio_url": audio_url, + "audio_filename": audio_filename, + "message": "Audio uploaded successfully" + } + except Exception as exc: + logger.error(f"[Podcast] Audio upload failed: {exc}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Audio upload failed: {str(exc)}") + + @router.post("/audio", response_model=PodcastAudioResponse) async def generate_podcast_audio( request: PodcastAudioRequest, diff --git a/backend/api/podcast/utils.py b/backend/api/podcast/utils.py index edb023a8..8c586570 100644 --- a/backend/api/podcast/utils.py +++ b/backend/api/podcast/utils.py @@ -10,6 +10,7 @@ from fastapi import HTTPException from loguru import logger from .constants import PODCAST_AUDIO_DIR, PODCAST_IMAGES_DIR +from utils.media_utils import load_media_bytes def load_podcast_audio_bytes(audio_url: str) -> bytes: @@ -54,49 +55,23 @@ def load_podcast_audio_bytes(audio_url: str) -> bytes: def load_podcast_image_bytes(image_url: str) -> bytes: - """Load podcast image bytes from URL. Only handles /api/podcast/images/ URLs.""" + """Load podcast image bytes from URL. Uses centralized media loader.""" if not image_url: raise HTTPException(status_code=400, detail="Image URL is required") logger.info(f"[Podcast] Loading image from URL: {image_url}") try: - parsed = urlparse(image_url) - path = parsed.path if parsed.scheme else image_url + # REUSE: Use centralized media loader which handles cross-module lookups + image_bytes = load_media_bytes(image_url) - # Only handle /api/podcast/images/ URLs - prefix = "/api/podcast/images/" - if prefix not in path: - logger.error(f"[Podcast] Unsupported image URL format: {image_url}") - raise HTTPException( - status_code=400, - detail=f"Unsupported image URL format: {image_url}. Only /api/podcast/images/ URLs are supported." - ) - - filename = path.split(prefix, 1)[1].split("?", 1)[0].strip() - if not filename: - logger.error(f"[Podcast] Could not extract filename from URL: {image_url}") - raise HTTPException(status_code=400, detail=f"Could not extract filename from URL: {image_url}") - - logger.info(f"[Podcast] Extracted filename: {filename}") - logger.info(f"[Podcast] PODCAST_IMAGES_DIR: {PODCAST_IMAGES_DIR}") - - # Podcast images are stored in podcast_images directory - image_path = (PODCAST_IMAGES_DIR / filename).resolve() - logger.info(f"[Podcast] Resolved image path: {image_path}") - - # Security check: ensure path is within PODCAST_IMAGES_DIR - if not str(image_path).startswith(str(PODCAST_IMAGES_DIR)): - logger.error(f"[Podcast] Attempted path traversal when resolving image: {image_url} -> {image_path}") - raise HTTPException(status_code=403, detail="Invalid image path") - - if not image_path.exists(): - logger.error(f"[Podcast] Image file not found: {image_path}") - raise HTTPException(status_code=404, detail=f"Image file not found: {filename}") - - image_bytes = image_path.read_bytes() - logger.info(f"[Podcast] ✅ Successfully loaded image: {len(image_bytes)} bytes from {image_path}") + if not image_bytes: + logger.error(f"[Podcast] Image file not found for URL: {image_url}") + raise HTTPException(status_code=404, detail=f"Image file not found: {image_url}") + + logger.info(f"[Podcast] ✅ Successfully loaded image: {len(image_bytes)} bytes") return image_bytes + except HTTPException: raise except Exception as exc: diff --git a/backend/api/subscription/routes/preflight.py b/backend/api/subscription/routes/preflight.py index 4a68a86c..2a55eedd 100644 --- a/backend/api/subscription/routes/preflight.py +++ b/backend/api/subscription/routes/preflight.py @@ -56,6 +56,8 @@ async def preflight_check( provider_enum = APIProvider.MISTRAL # Maps to HuggingFace elif provider_str == "video": provider_enum = APIProvider.VIDEO + elif provider_str == "fal-ai" or provider_str == "fal": + provider_enum = APIProvider.VIDEO # Map fal-ai to VIDEO as it's primarily used for media gen elif provider_str == "image_edit": provider_enum = APIProvider.IMAGE_EDIT elif provider_str == "stability": diff --git a/backend/api/video_studio/__init__.py b/backend/api/video_studio/__init__.py new file mode 100644 index 00000000..683d8f43 --- /dev/null +++ b/backend/api/video_studio/__init__.py @@ -0,0 +1 @@ +# Video Studio API Module diff --git a/backend/api/video_studio/handlers/avatar.py b/backend/api/video_studio/handlers/avatar.py new file mode 100644 index 00000000..f7570d2d --- /dev/null +++ b/backend/api/video_studio/handlers/avatar.py @@ -0,0 +1,173 @@ +from fastapi import APIRouter, UploadFile, File, Form, BackgroundTasks, HTTPException, Depends +from fastapi.responses import FileResponse +from typing import Optional, Dict, Any +import shutil +import os +from pathlib import Path +from services.wavespeed.infinitetalk import animate_scene_with_voiceover +from ..task_manager import task_manager +from middleware.auth_middleware import get_current_user +from loguru import logger +from services.database import get_engine_for_user +from sqlalchemy.orm import sessionmaker +from utils.asset_tracker import save_asset_to_library + +router = APIRouter() + +# Define storage directory +UPLOAD_DIR = Path("backend/data/video_studio/uploads") +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + +def _process_avatar_generation(task_id: str, image_path: Path, audio_path: Path, user_id: str, resolution: str, model: str): + """ + Background task to process avatar generation using shared InfiniteTalk service. + """ + try: + task_manager.update_task(task_id, "processing", user_id=user_id) + + # Read file bytes + with open(image_path, "rb") as f: + image_bytes = f.read() + with open(audio_path, "rb") as f: + audio_bytes = f.read() + + # Dummy scene data required by the service (used for prompt generation) + scene_data = { + "title": "Test Persona", + "description": "A talking avatar video generated via Video Studio." + } + story_context = {} + + # Call the common interface function + logger.info(f"[VideoStudio] Starting InfiniteTalk generation for task {task_id}") + result = animate_scene_with_voiceover( + image_bytes=image_bytes, + audio_bytes=audio_bytes, + scene_data=scene_data, + story_context=story_context, + user_id=user_id, + resolution=resolution + ) + + # Save the resulting video bytes to a file + video_filename = f"video_{task_id}.mp4" + video_path = UPLOAD_DIR / video_filename + with open(video_path, "wb") as f: + f.write(result["video_bytes"]) + + # Prepare result for frontend (remove raw bytes) + result.pop("video_bytes", None) + + # Add local download URL + video_url = f"/api/video-studio/download/{video_filename}" + result["video_url"] = video_url + + # Save asset to library + try: + engine = get_engine_for_user(user_id) + SessionLocal = sessionmaker(bind=engine) + db = SessionLocal() + try: + save_asset_to_library( + db=db, + user_id=user_id, + asset_type="video", + source_module="video_studio", + filename=video_filename, + file_url=video_url, + file_path=str(video_path), + file_size=video_path.stat().st_size, + mime_type="video/mp4", + title=f"Avatar Video {task_id}", + description=f"Generated avatar video using {model}", + model=model, + cost=result.get("cost", 0.0), + generation_time=result.get("generation_time", 0.0) + ) + finally: + db.close() + except Exception as e: + logger.error(f"[VideoStudio] Failed to save asset to library: {e}") + + logger.info(f"[VideoStudio] Task {task_id} completed successfully") + task_manager.update_task(task_id, "completed", result=result, user_id=user_id) + + except Exception as e: + logger.error(f"[VideoStudio] Avatar generation failed for task {task_id}: {e}", exc_info=True) + task_manager.update_task(task_id, "failed", error=str(e), user_id=user_id) + finally: + # Cleanup temp upload files + try: + if image_path.exists(): image_path.unlink() + if audio_path.exists(): audio_path.unlink() + except Exception as e: + logger.warning(f"[VideoStudio] Failed to cleanup temp files: {e}") + +@router.post("/avatar/create-async") +async def create_avatar_video( + background_tasks: BackgroundTasks, + image: UploadFile = File(...), + audio: UploadFile = File(...), + resolution: str = Form("720p"), + model: str = Form("infinitetalk"), + current_user: dict = Depends(get_current_user) +): + """ + Create a talking avatar video using InfiniteTalk (WaveSpeed). + Directly uses the common backend service without Podcast Maker dependencies. + """ + user_id = current_user.get("id", "anonymous") + + # Validate file types roughly + if not image.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="Invalid image file type") + + task_id = task_manager.create_task("avatar_generation", user_id=user_id) + + # Generate temp paths + image_ext = Path(image.filename).suffix or ".png" + audio_ext = Path(audio.filename).suffix or ".mp3" + image_path = UPLOAD_DIR / f"img_{task_id}{image_ext}" + audio_path = UPLOAD_DIR / f"aud_{task_id}{audio_ext}" + + try: + # Save uploaded files + with open(image_path, "wb") as f: + shutil.copyfileobj(image.file, f) + with open(audio_path, "wb") as f: + shutil.copyfileobj(audio.file, f) + + # Start background task + background_tasks.add_task( + _process_avatar_generation, + task_id, + image_path, + audio_path, + user_id, + resolution, + model + ) + + return {"task_id": task_id, "status": "pending", "message": "Video generation started successfully."} + + except Exception as e: + # Cleanup if immediate failure + if image_path.exists(): image_path.unlink() + if audio_path.exists(): audio_path.unlink() + logger.error(f"[VideoStudio] Failed to start generation: {e}") + raise HTTPException(status_code=500, detail=f"Failed to start generation: {str(e)}") + +@router.get("/task/{task_id}") +async def get_task_status(task_id: str, current_user: dict = Depends(get_current_user)): + user_id = current_user.get("id", "anonymous") + task = task_manager.get_task(task_id, user_id=user_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task + +@router.get("/download/{filename}") +async def download_video(filename: str): + file_path = UPLOAD_DIR / filename + if not file_path.exists(): + raise HTTPException(status_code=404, detail="File not found") + return FileResponse(file_path) diff --git a/backend/api/video_studio/router.py b/backend/api/video_studio/router.py new file mode 100644 index 00000000..5194c7a4 --- /dev/null +++ b/backend/api/video_studio/router.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter +from .handlers import avatar + +router = APIRouter(prefix="/api/video-studio", tags=["Video Studio"]) + +router.include_router(avatar.router) diff --git a/backend/api/video_studio/task_manager.py b/backend/api/video_studio/task_manager.py new file mode 100644 index 00000000..378b10b3 --- /dev/null +++ b/backend/api/video_studio/task_manager.py @@ -0,0 +1,126 @@ +from typing import Dict, Any, Optional +import uuid +from datetime import datetime +from loguru import logger +from sqlalchemy.orm import sessionmaker +from services.database import get_engine_for_user +from models.video_models import VideoGenerationTask, VideoTaskStatus, Base + +class TaskManager: + def __init__(self): + pass + + def create_task(self, task_type: str, user_id: str, request_data: Optional[Dict] = None) -> str: + """Create a new persistent task.""" + task_id = str(uuid.uuid4()) + + try: + engine = get_engine_for_user(user_id) + # Ensure table exists + Base.metadata.create_all(bind=engine) + + SessionLocal = sessionmaker(bind=engine) + db = SessionLocal() + + try: + task = VideoGenerationTask( + task_id=task_id, + user_id=user_id, + status=VideoTaskStatus.PENDING, + request_data=request_data + ) + db.add(task) + db.commit() + logger.info(f"[VideoStudio] Created persistent task {task_id} for user {user_id}") + return task_id + finally: + db.close() + except Exception as e: + logger.error(f"[VideoStudio] Failed to create task: {e}") + raise + + def update_task(self, task_id: str, status: str, result: Optional[Dict] = None, error: Optional[str] = None, user_id: str = None, progress: float = None, message: str = None): + """Update an existing task.""" + if not user_id: + logger.error(f"[VideoStudio] Cannot update task {task_id} without user_id") + return + + try: + engine = get_engine_for_user(user_id) + SessionLocal = sessionmaker(bind=engine) + db = SessionLocal() + + try: + task = db.query(VideoGenerationTask).filter(VideoGenerationTask.task_id == task_id).first() + if not task: + logger.warning(f"[VideoStudio] Task {task_id} not found in DB for update") + return + + # Map string status to Enum + try: + # Handle case-insensitive status mapping + status_upper = status.upper() + if status_upper == "RUNNING": + status_upper = "PROCESSING" + enum_status = VideoTaskStatus[status_upper] + except KeyError: + logger.warning(f"[VideoStudio] Invalid status {status}, defaulting to PROCESSING") + enum_status = VideoTaskStatus.PROCESSING + + task.status = enum_status + task.updated_at = datetime.utcnow() + + if result: + task.result = result + if error: + task.error = error + if progress is not None: + task.progress = progress + if message: + task.message = message + + db.commit() + logger.debug(f"[VideoStudio] Updated task {task_id} to {status}") + finally: + db.close() + except Exception as e: + logger.error(f"[VideoStudio] Failed to update task {task_id}: {e}") + + def get_task(self, task_id: str, user_id: str = None) -> Optional[Dict[str, Any]]: + """Retrieve task status.""" + if not user_id: + logger.error(f"[VideoStudio] Cannot get task {task_id} without user_id") + return None + + try: + engine = get_engine_for_user(user_id) + SessionLocal = sessionmaker(bind=engine) + db = SessionLocal() + + try: + task = db.query(VideoGenerationTask).filter(VideoGenerationTask.task_id == task_id).first() + if not task: + return None + + # Map internal status to frontend status + status_val = task.status.value + if status_val == "processing": + status_val = "running" + + return { + "task_id": task.task_id, + "status": status_val, + "result": task.result, + "error": task.error, + "progress": task.progress, + "message": task.message, + "created_at": task.created_at, + "updated_at": task.updated_at + } + finally: + db.close() + except Exception as e: + logger.error(f"[VideoStudio] Failed to get task {task_id}: {e}") + return None + +task_manager = TaskManager() diff --git a/backend/api/youtube/handlers/images.py b/backend/api/youtube/handlers/images.py index 09c5d3df..8403ab94 100644 --- a/backend/api/youtube/handlers/images.py +++ b/backend/api/youtube/handlers/images.py @@ -17,6 +17,7 @@ from services.subscription.preflight_validator import validate_image_generation_ from services.llm_providers.main_image_generation import generate_image, generate_character_image from utils.asset_tracker import save_asset_to_library from utils.logger_utils import get_service_logger +from utils.media_utils import load_media_bytes from ..task_manager import task_manager router = APIRouter(tags=["youtube-image"]) @@ -59,36 +60,15 @@ def require_authenticated_user(current_user: Dict[str, Any]) -> str: def _load_base_avatar_bytes(avatar_url: str) -> Optional[bytes]: """Load base avatar bytes for character consistency.""" - try: - # Handle different avatar URL formats - if avatar_url.startswith("/api/youtube/avatars/"): - # YouTube avatar - filename = avatar_url.split("/")[-1].split("?")[0] - avatar_path = YOUTUBE_AVATARS_DIR / filename - elif avatar_url.startswith("/api/podcast/avatars/"): - # Podcast avatar (cross-module usage) - filename = avatar_url.split("/")[-1].split("?")[0] - from pathlib import Path - podcast_avatars_dir = Path(__file__).parent.parent.parent.parent / "podcast_avatars" - avatar_path = podcast_avatars_dir / filename - else: - # Try to extract filename and check YouTube avatars first - filename = avatar_url.split("/")[-1].split("?")[0] - avatar_path = YOUTUBE_AVATARS_DIR / filename - if not avatar_path.exists(): - # Fallback to podcast avatars - podcast_avatars_dir = Path(__file__).parent.parent.parent.parent / "podcast_avatars" - avatar_path = podcast_avatars_dir / filename - - if not avatar_path.exists() or not avatar_path.is_file(): - logger.warning(f"[YouTube] Avatar file not found: {avatar_path}") - return None - - logger.info(f"[YouTube] Successfully loaded avatar: {avatar_path}") - return avatar_path.read_bytes() - except Exception as e: - logger.error(f"[YouTube] Error loading avatar from {avatar_url}: {e}") - return None + # REUSE: Use centralized media loader + avatar_bytes = load_media_bytes(avatar_url) + + if avatar_bytes: + logger.info(f"[YouTube] Successfully loaded avatar from: {avatar_url}") + return avatar_bytes + + logger.warning(f"[YouTube] Avatar file not found for URL: {avatar_url}") + return None def _save_scene_image(image_bytes: bytes, scene_id: str) -> Dict[str, str]: diff --git a/backend/app.py b/backend/app.py index 89f734bb..040a4ebb 100644 --- a/backend/app.py +++ b/backend/app.py @@ -74,6 +74,7 @@ from routers.linkedin import router as linkedin_router from api.linkedin_image_generation import router as linkedin_image_router from api.brainstorm import router as brainstorm_router from api.images import router as images_router +from api.assets_serving import router as assets_serving_router from routers.image_studio import router as image_studio_router from routers.product_marketing import router as product_marketing_router from routers.campaign_creator import router as campaign_creator_router @@ -132,6 +133,7 @@ from api.seo_dashboard import ( get_semantic_health # Phase 2B: Semantic health monitoring ) + # Initialize FastAPI app app = FastAPI( title="ALwrity Backend API", @@ -244,6 +246,9 @@ async def onboarding_status(): router_manager.include_core_routers() router_manager.include_optional_routers() +# Include assets serving router (must be mounted to serve generated images) +app.include_router(assets_serving_router) + # SEO Dashboard endpoints @app.get("/api/seo-dashboard/data") async def seo_dashboard_data(): diff --git a/backend/backend/data/video_studio/uploads/video_14ca69b3-be7f-472e-80cf-3bb1cf0acc2d.mp4 b/backend/backend/data/video_studio/uploads/video_14ca69b3-be7f-472e-80cf-3bb1cf0acc2d.mp4 new file mode 100644 index 00000000..53f4f390 Binary files /dev/null and b/backend/backend/data/video_studio/uploads/video_14ca69b3-be7f-472e-80cf-3bb1cf0acc2d.mp4 differ diff --git a/backend/backend/data/video_studio/uploads/video_7ac5ae18-4dd1-445d-b078-9a833b11bea9.mp4 b/backend/backend/data/video_studio/uploads/video_7ac5ae18-4dd1-445d-b078-9a833b11bea9.mp4 new file mode 100644 index 00000000..8b40abf9 Binary files /dev/null and b/backend/backend/data/video_studio/uploads/video_7ac5ae18-4dd1-445d-b078-9a833b11bea9.mp4 differ diff --git a/backend/backend/data/video_studio/uploads/video_9099def6-ffaf-4cb8-94b5-6fab018ae16f.mp4 b/backend/backend/data/video_studio/uploads/video_9099def6-ffaf-4cb8-94b5-6fab018ae16f.mp4 new file mode 100644 index 00000000..e3dae7c3 Binary files /dev/null and b/backend/backend/data/video_studio/uploads/video_9099def6-ffaf-4cb8-94b5-6fab018ae16f.mp4 differ diff --git a/backend/backend/data/video_studio/uploads/video_a95da0cf-6344-4a6d-9358-19e9ddb55320.mp4 b/backend/backend/data/video_studio/uploads/video_a95da0cf-6344-4a6d-9358-19e9ddb55320.mp4 new file mode 100644 index 00000000..90f33e5a Binary files /dev/null and b/backend/backend/data/video_studio/uploads/video_a95da0cf-6344-4a6d-9358-19e9ddb55320.mp4 differ diff --git a/backend/backend/data/video_studio/uploads/video_c1f515ea-d48c-4213-bdfd-30e4a4eca81b.mp4 b/backend/backend/data/video_studio/uploads/video_c1f515ea-d48c-4213-bdfd-30e4a4eca81b.mp4 new file mode 100644 index 00000000..461b3d36 Binary files /dev/null and b/backend/backend/data/video_studio/uploads/video_c1f515ea-d48c-4213-bdfd-30e4a4eca81b.mp4 differ diff --git a/backend/backend/data/video_studio/uploads/video_eb4ef955-832f-4ec3-b9e0-17eed0e01d0b.mp4 b/backend/backend/data/video_studio/uploads/video_eb4ef955-832f-4ec3-b9e0-17eed0e01d0b.mp4 new file mode 100644 index 00000000..70c9ea96 Binary files /dev/null and b/backend/backend/data/video_studio/uploads/video_eb4ef955-832f-4ec3-b9e0-17eed0e01d0b.mp4 differ diff --git a/backend/middleware/auth_middleware.py b/backend/middleware/auth_middleware.py index 2b2c8da0..6fd9fdb5 100644 --- a/backend/middleware/auth_middleware.py +++ b/backend/middleware/auth_middleware.py @@ -38,6 +38,7 @@ class ClerkAuthMiddleware: ) self.clerk_publishable_key = publishable_key.strip() if publishable_key else None self.disable_auth = os.getenv('DISABLE_AUTH', 'false').lower() == 'true' + self.allow_unverified_dev = os.getenv('ALLOW_UNVERIFIED_JWT_DEV', 'false').lower() == 'true' # Cache for PyJWKClient to avoid repeated JWKS fetches self._jwks_client_cache = {} @@ -67,6 +68,7 @@ class ClerkAuthMiddleware: # Create ClerkHTTPBearer instance for dependency injection self.clerk_bearer = ClerkHTTPBearer(clerk_config) logger.info(f"fastapi-clerk-auth initialized successfully with JWKS URL: {jwks_url}") + self._jwks_url_cache = jwks_url else: logger.warning("Could not extract instance from publishable key") self.clerk_bearer = None @@ -113,7 +115,9 @@ class ClerkAuthMiddleware: issuer = unverified_claims.get('iss', '') # Construct JWKS URL from issuer - jwks_url = f"{issuer}/.well-known/jwks.json" + jwks_url = f"{issuer}/.well-known/jwks.json" if issuer else self._jwks_url_cache or "" + if not jwks_url: + raise Exception("Unable to resolve JWKS URL for Clerk verification") # Use cached PyJWKClient to avoid repeated JWKS fetches if jwks_url not in self._jwks_client_cache: @@ -162,11 +166,37 @@ class ClerkAuthMiddleware: if 'expired' in error_msg or 'signature has expired' in error_msg: logger.debug(f"Token expired (expected): {e}") else: - logger.warning(f"fastapi-clerk-auth verification error: {e}") + logger.warning(f"fastapi-clerk-auth verification error: {e}. Attempting fallback decoding.") + + # Fallback to unverified decoding on verification failure (DEV MODE ONLY) + try: + import jwt + # Decode the JWT without verification to get claims + decoded_token = jwt.decode(token, options={"verify_signature": False}, leeway=300) + user_id = decoded_token.get('sub') + email = decoded_token.get('email') + first_name = decoded_token.get('first_name') or decoded_token.get('given_name') + last_name = decoded_token.get('last_name') or decoded_token.get('family_name') + + if user_id and self.allow_unverified_dev: + logger.debug(f"Unverified token accepted (dev) for user: {email or 'unknown'} (ID: {user_id})") + return { + 'id': user_id, + 'email': email, + 'first_name': first_name, + 'last_name': last_name, + 'clerk_user_id': user_id + } + elif user_id and not self.allow_unverified_dev: + logger.error("Unverified token rejected (production).") + return None + except Exception as fallback_e: + logger.warning(f"Fallback decoding failed: {fallback_e}") + return None else: # Fallback to custom implementation (not secure for production) - logger.warning("Using fallback JWT decoding without signature verification") + logger.debug("Using fallback JWT decoding without signature verification") try: import jwt # Decode the JWT without verification to get claims @@ -188,14 +218,17 @@ class ClerkAuthMiddleware: logger.warning("No user ID found in token") return None - logger.info(f"Token decoded successfully (fallback) for user: {email} (ID: {user_id})") - return { - 'id': user_id, - 'email': email, - 'first_name': first_name, - 'last_name': last_name, - 'clerk_user_id': user_id - } + if self.allow_unverified_dev: + logger.debug(f"Token decoded successfully (fallback dev) for user: {email} (ID: {user_id})") + return { + 'id': user_id, + 'email': email, + 'first_name': first_name, + 'last_name': last_name, + 'clerk_user_id': user_id + } + logger.error("Fallback decoding is disabled in production.") + return None except Exception as e: logger.warning(f"Fallback JWT decode error: {e}") diff --git a/backend/models/content_asset_models.py b/backend/models/content_asset_models.py index 0f7afc7c..631f45e9 100644 --- a/backend/models/content_asset_models.py +++ b/backend/models/content_asset_models.py @@ -55,6 +55,15 @@ class AssetSource(enum.Enum): # YouTube Creator YOUTUBE_CREATOR = "youtube_creator" + # Brand Avatar Generator + BRAND_AVATAR_GENERATOR = "brand_avatar_generator" + + # Video Studio + VIDEO_STUDIO = "video_studio" + + # Voice Cloner + VOICE_CLONER = "voice_cloner" + class ContentAsset(Base): """ diff --git a/backend/models/video_models.py b/backend/models/video_models.py new file mode 100644 index 00000000..c4603cd5 --- /dev/null +++ b/backend/models/video_models.py @@ -0,0 +1,36 @@ +from sqlalchemy import Column, Integer, String, DateTime, JSON, Text, Float, Enum +from datetime import datetime +import enum +from models.subscription_models import Base + +class VideoTaskStatus(enum.Enum): + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + +class VideoGenerationTask(Base): + """ + Model for tracking video generation tasks (Video Studio). + """ + __tablename__ = "video_generation_tasks" + + id = Column(Integer, primary_key=True, index=True) + task_id = Column(String(36), unique=True, index=True, nullable=False) # UUID + user_id = Column(String(255), nullable=False, index=True) + + status = Column(Enum(VideoTaskStatus), default=VideoTaskStatus.PENDING) + + # Task inputs (stored as JSON) + request_data = Column(JSON, nullable=True) + + # Task results + result = Column(JSON, nullable=True) + error = Column(Text, nullable=True) + + # Progress tracking + progress = Column(Float, default=0.0) + message = Column(String(255), nullable=True) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/routers/gsc_auth.py b/backend/routers/gsc_auth.py index 165c74bd..e58b89b3 100644 --- a/backend/routers/gsc_auth.py +++ b/backend/routers/gsc_auth.py @@ -43,6 +43,12 @@ async def get_gsc_auth_url(user: dict = Depends(get_current_user)): logger.info(f"OAuth URL: {auth_url[:100]}...") return {"auth_url": auth_url} + except FileNotFoundError as e: + logger.error(f"GSC credentials not found: {e}") + raise HTTPException( + status_code=503, + detail="Google Search Console integration is not configured. Please add gsc_credentials.json to the backend directory or set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables." + ) except Exception as e: logger.error(f"Error generating GSC OAuth URL: {e}") logger.error(f"Error details: {str(e)}") @@ -73,34 +79,29 @@ async def handle_gsc_callback( from services.platform_insights_monitoring_service import create_platform_insights_task # Get user_id from state (stored during OAuth flow) - # Note: handle_oauth_callback already deleted state, so we need to get user_id from recent credentials - db = SessionLocal() - try: - # Get user_id from most recent GSC credentials (since state was deleted) - import sqlite3 - with sqlite3.connect(gsc_service.db_path) as conn: - cursor = conn.cursor() - cursor.execute('SELECT user_id FROM gsc_credentials ORDER BY updated_at DESC LIMIT 1') - result = cursor.fetchone() - if result: - user_id = result[0] - - # Don't fetch site_url here - it requires API calls - # The executor will fetch it when the task runs (weekly) - # Create insights task without site_url to avoid API calls - task_result = create_platform_insights_task( - user_id=user_id, - platform='gsc', - site_url=None, # Will be fetched by executor when task runs - db=db - ) - - if task_result.get('success'): - logger.info(f"Created GSC insights task for user {user_id}") - else: - logger.warning(f"Failed to create GSC insights task: {task_result.get('error')}") - finally: - db.close() + # Format is "user_id:random_string" + user_id = state.split(':')[0] if ':' in state else None + + if user_id: + db = SessionLocal() + try: + # Create insights task without site_url to avoid API calls + # The executor will fetch it when the task runs (weekly) + task_result = create_platform_insights_task( + user_id=user_id, + platform='gsc', + site_url=None, # Will be fetched by executor when task runs + db=db + ) + + if task_result.get('success'): + logger.info(f"Created GSC insights task for user {user_id}") + else: + logger.warning(f"Failed to create GSC insights task: {task_result.get('error')}") + finally: + db.close() + else: + logger.warning(f"Could not extract user_id from state: {state}") except Exception as e: # Non-critical: log but don't fail OAuth callback logger.warning(f"Failed to create GSC insights task after OAuth: {e}", exc_info=True) diff --git a/backend/routers/wordpress_oauth.py b/backend/routers/wordpress_oauth.py index eea6815e..abc10f8c 100644 --- a/backend/routers/wordpress_oauth.py +++ b/backend/routers/wordpress_oauth.py @@ -3,8 +3,8 @@ WordPress OAuth2 Routes Handles WordPress.com OAuth2 authentication flow. """ -from fastapi import APIRouter, Depends, HTTPException, status, Query -from fastapi.responses import RedirectResponse, HTMLResponse +from fastapi import APIRouter, Depends, HTTPException, status, Query, Request +from fastapi.responses import RedirectResponse, HTMLResponse, JSONResponse from typing import Dict, Any, Optional from pydantic import BaseModel from loguru import logger @@ -61,14 +61,23 @@ async def get_wordpress_auth_url( @router.get("/callback") async def handle_wordpress_callback( + request: Request, code: str = Query(..., description="Authorization code from WordPress"), state: str = Query(..., description="State parameter for security"), error: Optional[str] = Query(None, description="Error from WordPress OAuth") ): """Handle WordPress OAuth2 callback.""" try: + # Check if JSON response is requested + wants_json = request.headers.get("accept") == "application/json" or request.query_params.get("format") == "json" + if error: logger.error(f"WordPress OAuth error: {error}") + if wants_json: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"success": False, "error": error} + ) html_content = f""" @@ -77,7 +86,7 @@ async def handle_wordpress_callback(