diff --git a/backend/api/podcast/constants.py b/backend/api/podcast/constants.py index edc4e235..4a954a60 100644 --- a/backend/api/podcast/constants.py +++ b/backend/api/podcast/constants.py @@ -10,22 +10,32 @@ from loguru import logger from services.story_writer.audio_generation_service import StoryAudioGenerationService # Directory paths -# router.py is at: backend/api/podcast/router.py -# parents[0] = backend/api/podcast/ -# parents[1] = backend/api/ -# parents[2] = backend/ -# parents[3] = root/ -ROOT_DIR = Path(__file__).resolve().parents[3] # root/ -DATA_MEDIA_DIR = ROOT_DIR / "data" / "media" +# Find root by looking for 'data' or 'backend' folder +def _find_root() -> Path: + """Find project root by searching up for data directory.""" + current = Path(__file__).resolve() + for _ in range(10): # max 10 levels up + if (current / "data").exists() and (current / "data" / "media").exists(): + return current + if (current / "backend").exists(): + return current / "backend" + parent = current.parent + if parent == current: + break + current = parent + # Fallback: assume backend is root + return Path(__file__).resolve().parents[1] -PODCAST_AUDIO_DIR = (DATA_MEDIA_DIR / "podcast_audio").resolve() -PODCAST_IMAGES_DIR = (DATA_MEDIA_DIR / "podcast_images").resolve() -PODCAST_VIDEOS_DIR = (DATA_MEDIA_DIR / "podcast_videos").resolve() +ROOT_DIR = _find_root() -# Video subdirectory +# Video subdirectory (relative to workspace media dir) AI_VIDEO_SUBDIR = Path("AI_Videos") -MediaType = Literal["audio", "image", "video"] +# Legacy constants - DEPRECATED, use get_podcast_media_dir() instead +# Kept for backward compatibility with some handlers +PODCAST_AVATARS_SUBDIR = Path("avatars") + +MediaType = Literal["audio", "image", "video", "chart"] def _sanitize_user_id(user_id: str) -> str: @@ -38,21 +48,31 @@ def get_podcast_media_dir( *, ensure_exists: bool = False, ) -> Path: - """Resolve podcast media directory (tenant workspace first, legacy global fallback).""" + """ + Resolve podcast media directory (workspace-only for multi-tenant isolation). + + Always requires user_id for tenant isolation. Falls back to default workspace + only if no user_id provided (for backward compat in development). + """ media_subdir = { "audio": "podcast_audio", "image": "podcast_images", "video": "podcast_videos", + "chart": "podcast_charts", }[media_type] if user_id: sanitized = _sanitize_user_id(user_id) - tenant_media_dir = ROOT_DIR / "workspace" / f"workspace_{sanitized}" / "media" / media_subdir - resolved_dir = tenant_media_dir.resolve() + resolved_dir = ( + ROOT_DIR / "workspace" / f"workspace_{sanitized}" / "media" / media_subdir + ).resolve() else: - resolved_dir = (DATA_MEDIA_DIR / media_subdir).resolve() + # Development fallback: use a default workspace + resolved_dir = ( + ROOT_DIR / "workspace" / "workspace_alwrity" / "media" / media_subdir + ).resolve() - logger.debug(f"[Podcast] get_podcast_media_dir: type={media_type}, user_id={user_id}, sanitized={user_id and _sanitize_user_id(user_id)}, resolved={resolved_dir}") + logger.warning(f"[Podcast] get_podcast_media_dir: type={media_type}, user_id={user_id}, resolved={resolved_dir}") if ensure_exists: resolved_dir.mkdir(parents=True, exist_ok=True) @@ -61,14 +81,11 @@ def get_podcast_media_dir( def get_podcast_media_read_dirs(media_type: MediaType, user_id: str | None = None) -> list[Path]: - """Return ordered directories to search (tenant path first, then legacy global path).""" - dirs: list[Path] = [] - if user_id: - dirs.append(get_podcast_media_dir(media_type, user_id)) - logger.debug(f"[Podcast] get_podcast_media_read_dirs: added user dir for {user_id}") - dirs.append(get_podcast_media_dir(media_type, None)) - logger.debug(f"[Podcast] get_podcast_media_read_dirs: dirs={dirs}") - return dirs + """ + Return directories to search for podcast media. + Now workspace-only (no legacy fallback). + """ + return [get_podcast_media_dir(media_type, user_id)] def get_podcast_audio_service(user_id: str | None = None) -> StoryAudioGenerationService: diff --git a/backend/api/podcast/handlers/analysis.py b/backend/api/podcast/handlers/analysis.py index 14b9b680..6ba0743c 100644 --- a/backend/api/podcast/handlers/analysis.py +++ b/backend/api/podcast/handlers/analysis.py @@ -20,7 +20,7 @@ from services.podcast_bible_service import PodcastBibleService from utils.asset_tracker import save_asset_to_library from loguru import logger import os -from ..constants import PODCAST_IMAGES_DIR +from ..constants import get_podcast_media_dir from ..models import ( PodcastAnalyzeRequest, PodcastAnalyzeResponse, @@ -247,7 +247,8 @@ async def analyze_podcast_idea( if image_result and image_result.image_bytes: img_id = str(uuid.uuid4())[:8] filename = f"presenter_podcast_{user_id}_{img_id}.png" - avatars_dir = PODCAST_IMAGES_DIR / "avatars" + images_dir = get_podcast_media_dir("image", user_id, ensure_exists=True) + avatars_dir = images_dir / "avatars" avatars_dir.mkdir(parents=True, exist_ok=True) output_path = avatars_dir / filename diff --git a/backend/api/podcast/handlers/audio.py b/backend/api/podcast/handlers/audio.py index 7f63dc77..715fdc63 100644 --- a/backend/api/podcast/handlers/audio.py +++ b/backend/api/podcast/handlers/audio.py @@ -12,7 +12,15 @@ from pathlib import Path from urllib.parse import urlparse import tempfile import uuid +import hashlib +import time import shutil +import requests +import asyncio +from concurrent.futures import ThreadPoolExecutor + +import asyncio +from concurrent.futures import ThreadPoolExecutor from services.database import get_db from middleware.auth_middleware import get_current_user, get_current_user_with_query_token @@ -31,6 +39,124 @@ from ..models import ( router = APIRouter() +# Thread pool for CPU/IO-intensive voice clone operations +_audio_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="podcast_audio") + +# In-memory LRU cache for voice samples (per user) to avoid re-downloading +_voice_sample_cache: dict[str, tuple[float, bytes]] = {} +_VOICE_SAMPLE_CACHE_TTL = 1800 # 30 minutes + + +def _get_cached_voice_sample(cache_key: str) -> Optional[bytes]: + """Get voice sample bytes from in-memory cache if fresh.""" + if cache_key in _voice_sample_cache: + ts, data = _voice_sample_cache[cache_key] + if time.time() - ts < _VOICE_SAMPLE_CACHE_TTL: + logger.debug(f"[Podcast] Voice sample cache hit for {cache_key[:16]}...") + return data + del _voice_sample_cache[cache_key] + return None + + +def _cache_voice_sample(cache_key: str, data: bytes) -> None: + """Store voice sample bytes in in-memory cache.""" + # Evict oldest entries if cache grows too large + if len(_voice_sample_cache) > 50: + oldest_key = min(_voice_sample_cache, key=lambda k: _voice_sample_cache[k][0]) + del _voice_sample_cache[oldest_key] + _voice_sample_cache[cache_key] = (time.time(), data) + + +def _get_latest_voice_sample_url(user_id: str, db) -> Optional[str]: + """Get the latest voice sample URL for a user from their voice clone assets.""" + try: + from models.content_asset_models import ContentAsset, AssetType, AssetSource + from sqlalchemy import desc + + 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 asset and asset.file_url: + logger.info(f"[Podcast] Found voice sample for user {user_id}: {asset.file_url}") + return asset.file_url + + logger.warning(f"[Podcast] No voice sample asset found for user {user_id}") + return None + except Exception as e: + logger.error(f"[Podcast] Error fetching voice sample URL: {e}") + return None + + +def _fetch_voice_sample(voice_sample_url: str, user_id: str) -> Optional[bytes]: + """Fetch voice sample audio bytes from URL, with caching.""" + cache_key = hashlib.md5(f"{user_id}:{voice_sample_url}".encode()).hexdigest() + + # Check in-memory cache first + cached = _get_cached_voice_sample(cache_key) + if cached is not None: + return cached + + try: + from utils.media_utils import resolve_media_path + + # Try resolving as a local workspace path first (fastest) + if "/api/assets/" in voice_sample_url: + # Resolve user workspace path directly + sanitized_uid = "".join(c for c in user_id if c.isalnum() or c in ("-", "_")) + from api.podcast.constants import ROOT_DIR + parts = voice_sample_url.split("/") + # Expected: /api/assets/{user_id}/voice_samples/{filename} + try: + idx = parts.index("voice_samples") + filename = parts[idx + 1].split("?")[0] + local_path = ROOT_DIR / "workspace" / f"workspace_{sanitized_uid}" / "assets" / "voice_samples" / filename + if local_path.exists(): + data = local_path.read_bytes() + _cache_voice_sample(cache_key, data) + logger.info(f"[Podcast] Voice sample loaded from workspace: {local_path}") + return data + except (ValueError, IndexError): + pass + + # Fall back to media utils resolver + local_path = resolve_media_path(voice_sample_url) + if local_path and local_path.exists(): + data = local_path.read_bytes() + _cache_voice_sample(cache_key, data) + return data + + # Try resolving as a podcast audio file + if "/api/podcast/audio/" in voice_sample_url: + filename = voice_sample_url.split("/api/podcast/audio/")[-1].split("?")[0] + try: + audio_dir = get_podcast_media_dir("audio", user_id) + local_path = audio_dir / filename + if local_path.exists(): + data = local_path.read_bytes() + _cache_voice_sample(cache_key, data) + return data + except Exception: + pass + + # Try direct HTTP fetch as fallback + if voice_sample_url.startswith("http"): + logger.info(f"[Podcast] Fetching voice sample via HTTP: {voice_sample_url[:80]}...") + resp = requests.get(voice_sample_url, timeout=30) + if resp.status_code == 200: + data = resp.content + _cache_voice_sample(cache_key, data) + logger.info(f"[Podcast] Voice sample fetched via HTTP ({len(data)} bytes)") + return data + + logger.warning(f"[Podcast] Could not fetch voice sample from: {voice_sample_url}") + return None + except Exception as e: + logger.error(f"[Podcast] Error fetching voice sample: {e}") + return None + @router.post("/audio/upload") async def upload_podcast_audio( @@ -125,35 +251,176 @@ async def generate_podcast_audio( raise HTTPException(status_code=400, detail="Text is required") try: - audio_service = get_podcast_audio_service(user_id) - logger.warning(f"[Podcast] Generating audio with service dir: {audio_service.output_dir}") - result: StoryAudioResult = audio_service.generate_ai_audio( - scene_number=0, - scene_title=request.scene_title, - text=request.text.strip(), - user_id=user_id, - voice_id=request.voice_id or "Wise_Woman", - custom_voice_id=request.custom_voice_id, - speed=request.speed or 1.0, # Normal speed (was 0.9, but too slow - causing duration issues) - volume=request.volume or 1.0, - pitch=request.pitch or 0.0, # Normal pitch (0.0 = neutral) - emotion=request.emotion or "neutral", - english_normalization=request.english_normalization or False, - sample_rate=request.sample_rate, - bitrate=request.bitrate, - channel=request.channel, - format=request.format, - language_boost=request.language_boost, - enable_sync_mode=request.enable_sync_mode, + # Determine if we should use voice clone path + # Voice clone is used when: explicitly requested, OR when voice_id/custom_voice_id indicates a clone + # (cloned voice IDs start with "vc_" or match the placeholder "MY_VOICE_CLONE") + _vid = request.voice_id or "" + _cvid = request.custom_voice_id or "" + is_voice_clone = request.use_voice_clone or ( + _cvid.startswith("vc_") or _cvid == "MY_VOICE_CLONE" + ) or ( + _vid.startswith("vc_") or _vid == "MY_VOICE_CLONE" ) - # Override URL to use podcast endpoint instead of story endpoint - if result.get("audio_url") and "/api/story/audio/" in result.get("audio_url", ""): - audio_filename = result.get("audio_filename", "") - result["audio_url"] = f"/api/podcast/audio/{audio_filename}" - - logger.warning(f"[Podcast] Audio generated - path: {result.get('audio_path')}, url: {result.get('audio_url')}") + # If voice_id is a clone ID, normalize it to use Wise_Woman for TTS fallback + effective_voice_id = _vid if not (_vid.startswith("vc_") or _vid == "MY_VOICE_CLONE") else "Wise_Woman" + + logger.warning(f"[Podcast] Audio request: use_voice_clone={request.use_voice_clone}, voice_id={request.voice_id}, custom_voice_id={request.custom_voice_id}, is_voice_clone={is_voice_clone}, voice_sample_url={request.voice_sample_url}, voice_clone_engine={request.voice_clone_engine}") + + # Voice clone path: use user's voice sample with scene text as reference + if is_voice_clone: + # If no voice_sample_url provided, try to fetch it from the user's latest voice clone + voice_sample_url = request.voice_sample_url + if not voice_sample_url: + try: + voice_sample_url = _get_latest_voice_sample_url(user_id, db) + logger.warning(f"[Podcast] DB fallback voice sample URL for user {user_id}: {voice_sample_url}") + except Exception as e: + logger.warning(f"[Podcast] Could not fetch voice sample URL: {e}") + + if voice_sample_url: + from services.llm_providers.main_audio_generation import qwen3_voice_clone, cosyvoice_voice_clone + + engine = (request.voice_clone_engine or "qwen3").lower() + logger.warning(f"[Podcast] 🔊 Voice clone path: engine={engine}, scene='{request.scene_title}', voice_sample_url={voice_sample_url[:80]}...") + + # Download voice sample from URL (with caching) + logger.warning(f"[Podcast] Fetching voice sample from: {voice_sample_url}") + try: + voice_sample_bytes = _fetch_voice_sample(voice_sample_url, user_id) + except Exception as fetch_err: + logger.error(f"[Podcast] ❌ Failed to fetch voice sample: {fetch_err}", exc_info=True) + raise HTTPException(status_code=400, detail=f"Could not fetch voice sample: {str(fetch_err)}") + logger.warning(f"[Podcast] Voice sample fetch result: {len(voice_sample_bytes) if voice_sample_bytes else 0} bytes") + if not voice_sample_bytes: + raise HTTPException(status_code=400, detail=f"Could not fetch voice sample from {voice_sample_url}") + + scene_text = request.text.strip() + if len(scene_text) > 4000: + scene_text = scene_text[:4000] + + # Run voice clone in thread pool to avoid blocking the event loop + loop = asyncio.get_event_loop() + + try: + if engine == "minimax": + from services.llm_providers.main_audio_generation import clone_voice + import random + import string + random_suffix = ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + custom_vid = request.custom_voice_id or f"vc_{random_suffix}" + + result_obj = await loop.run_in_executor( + _audio_executor, + lambda cv=custom_vid: clone_voice( + audio_bytes=voice_sample_bytes, + custom_voice_id=cv, + text=scene_text, + user_id=user_id, + ), + ) + audio_bytes = result_obj.preview_audio_bytes + provider = "minimax" + model = "minimax/voice-clone" + elif engine == "cosyvoice": + result_obj = await loop.run_in_executor( + _audio_executor, + lambda: cosyvoice_voice_clone( + audio_bytes=voice_sample_bytes, + text=scene_text, + user_id=user_id, + ), + ) + audio_bytes = result_obj.preview_audio_bytes + provider = "wavespeed-ai" + model = "wavespeed-ai/cosyvoice-tts/voice-clone" + else: + result_obj = await loop.run_in_executor( + _audio_executor, + lambda: qwen3_voice_clone( + audio_bytes=voice_sample_bytes, + text=scene_text, + user_id=user_id, + ), + ) + audio_bytes = result_obj.preview_audio_bytes + provider = "wavespeed-ai" + model = "wavespeed-ai/qwen3-tts/voice-clone" + + logger.warning(f"[Podcast] 🔊 Voice clone result: {len(audio_bytes) if audio_bytes else 0} bytes, provider={provider}") + except HTTPException: + raise + except Exception as clone_err: + logger.error(f"[Podcast] ❌ Voice clone failed: {clone_err}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Voice clone generation failed: {str(clone_err)}") + + # Save audio bytes to file + audio_service = get_podcast_audio_service(user_id) + audio_filename = f"scene_{request.scene_id}_{uuid.uuid4().hex[:8]}.mp3" + audio_path = audio_service.output_dir / audio_filename + + with open(audio_path, "wb") as f: + f.write(audio_bytes) + + file_size = len(audio_bytes) + audio_url = f"/api/podcast/audio/{audio_filename}" + cost = max(0.005, 0.005 * (len(scene_text) / 100.0)) + + result = { + "audio_path": str(audio_path), + "audio_filename": audio_filename, + "audio_url": audio_url, + "file_size": file_size, + "provider": provider, + "model": model, + "cost": cost, + "scene_number": 0, + "scene_title": request.scene_title, + } + + else: + # Standard TTS path - but NOT if custom_voice_id is a clone ID + # Clone IDs (vc_*, MY_VOICE_CLONE) are not valid for minimax TTS + if is_voice_clone: + logger.warning(f"[Podcast] ⚠️ Voice clone detected but no voice sample available - falling back to standard TTS with voice_id={effective_voice_id}") + effective_custom_voice_id = request.custom_voice_id + if effective_custom_voice_id and ( + effective_custom_voice_id.startswith("vc_") or + effective_custom_voice_id == "MY_VOICE_CLONE" + ): + logger.warning(f"[Podcast] Ignoring clone ID '{effective_custom_voice_id}' in standard TTS path - no voice sample URL available") + effective_custom_voice_id = None + + audio_service = get_podcast_audio_service(user_id) + logger.warning(f"[Podcast] Standard TTS path: voice_id={effective_voice_id}, custom_voice_id={effective_custom_voice_id}") + result: StoryAudioResult = audio_service.generate_ai_audio( + scene_number=0, + scene_title=request.scene_title, + text=request.text.strip(), + user_id=user_id, + voice_id=effective_voice_id, + custom_voice_id=effective_custom_voice_id, + speed=request.speed or 1.0, # Normal speed (was 0.9, but too slow - causing duration issues) + volume=request.volume or 1.0, + pitch=request.pitch or 0.0, # Normal pitch (0.0 = neutral) + emotion=request.emotion or "neutral", + english_normalization=request.english_normalization or False, + sample_rate=request.sample_rate, + bitrate=request.bitrate, + channel=request.channel, + format=request.format, + language_boost=request.language_boost, + enable_sync_mode=request.enable_sync_mode, + ) + + # Override URL to use podcast endpoint instead of story endpoint + if result.get("audio_url") and "/api/story/audio/" in result.get("audio_url", ""): + audio_filename = result.get("audio_filename", "") + result["audio_url"] = f"/api/podcast/audio/{audio_filename}" + + logger.warning(f"[Podcast] Audio generated - path: {result.get('audio_path')}, url: {result.get('audio_url')}") except Exception as exc: + logger.error(f"[Podcast] ❌ Audio generation failed: {exc}", exc_info=True) raise HTTPException(status_code=500, detail=f"Audio generation failed: {exc}") # Save to asset library (podcast module) diff --git a/backend/api/podcast/handlers/avatar.py b/backend/api/podcast/handlers/avatar.py index bc88fe7c..e197f5c0 100644 --- a/backend/api/podcast/handlers/avatar.py +++ b/backend/api/podcast/handlers/avatar.py @@ -19,15 +19,18 @@ from services.llm_providers.main_image_generation import generate_image from services.llm_providers.main_image_editing import edit_image from utils.asset_tracker import save_asset_to_library from loguru import logger -from ..constants import PODCAST_IMAGES_DIR +from ..constants import get_podcast_media_dir, PODCAST_AVATARS_SUBDIR from ..presenter_personas import choose_persona_id, get_persona router = APIRouter() # Avatar subdirectory -AVATAR_SUBDIR = "avatars" -PODCAST_AVATARS_DIR = PODCAST_IMAGES_DIR / AVATAR_SUBDIR -PODCAST_AVATARS_DIR.mkdir(parents=True, exist_ok=True) +AVATAR_SUBDIR = PODCAST_AVATARS_SUBDIR + + +def _get_podcast_avatars_dir(user_id: str) -> Path: + """Get podcast avatars directory for a user (workspace-aware).""" + return get_podcast_media_dir("image", user_id, ensure_exists=True) / AVATAR_SUBDIR @router.post("/avatar/upload") @@ -57,7 +60,8 @@ async def upload_podcast_avatar( file_ext = Path(file.filename).suffix or '.png' unique_id = str(uuid.uuid4())[:8] avatar_filename = f"avatar_{project_id or 'temp'}_{unique_id}{file_ext}" - avatar_path = PODCAST_AVATARS_DIR / avatar_filename + avatars_dir = _get_podcast_avatars_dir(user_id) + avatar_path = avatars_dir / avatar_filename # Save file with open(avatar_path, "wb") as f: @@ -163,7 +167,8 @@ async def make_avatar_presentable( # Save transformed avatar unique_id = str(uuid.uuid4())[:8] transformed_filename = f"presenter_transformed_{project_id or 'temp'}_{unique_id}.png" - transformed_path = PODCAST_AVATARS_DIR / transformed_filename + avatars_dir = _get_podcast_avatars_dir(user_id) + transformed_path = avatars_dir / transformed_filename with open(transformed_path, "wb") as f: f.write(result.image_bytes) @@ -345,7 +350,8 @@ async def generate_podcast_presenters( # Save avatar unique_id = str(uuid.uuid4())[:8] avatar_filename = f"presenter_{project_id or 'temp'}_{i+1}_{unique_id}.png" - avatar_path = PODCAST_AVATARS_DIR / avatar_filename + avatars_dir = _get_podcast_avatars_dir(user_id) + avatar_path = avatars_dir / avatar_filename with open(avatar_path, "wb") as f: f.write(result.image_bytes) diff --git a/backend/api/podcast/handlers/broll.py b/backend/api/podcast/handlers/broll.py index 7f51fd4a..26c902f3 100644 --- a/backend/api/podcast/handlers/broll.py +++ b/backend/api/podcast/handlers/broll.py @@ -191,8 +191,11 @@ async def generate_chart_preview( """ user_id = require_authenticated_user(current_user) + # Debug logging + logger.warning(f"[Broll] Chart preview request: type={request.chart_type}, title={request.title}, chart_data keys={list(request.chart_data.keys())}, user_id={user_id}") + try: - broll_service = get_broll_service() + broll_service = get_broll_service(user_id=user_id) chart_id = uuid.uuid4().hex[:8] preview_path = broll_service.generate_chart_preview( @@ -203,11 +206,17 @@ async def generate_chart_preview( chart_id=chart_id, ) + # If chart generation failed (empty path), return a placeholder instead of 500 if not preview_path: - raise HTTPException(status_code=500, detail="Failed to generate chart preview") + # Return a fallback response so frontend doesn't crash + logger.warning(f"[Broll] Chart preview skipped - invalid data for type: {request.chart_type}") + return ChartPreviewResponse( + preview_url="", + chart_id=chart_id, + ) preview_filename = Path(preview_path).name - preview_url = f"/api/podcast/broll/preview/{chart_id}/{preview_filename}" + preview_url = f"/api/podcast/preview/{chart_id}/{preview_filename}" return ChartPreviewResponse( preview_url=preview_url, @@ -324,17 +333,29 @@ async def compose_broll_videos( async def serve_chart_preview( chart_id: str, filename: str, - current_user: Dict[str, Any] = Depends(get_current_user), + user_id: Optional[str] = None, ): - """Serve chart preview PNG files.""" - user_id = require_authenticated_user(current_user) + """ + Serve chart preview PNG files. - broll_service = get_broll_service() + - user_id passed as query param for multi-tenant workspace resolution + - endpoint is public (no auth) to allow direct image loading in browser + """ + # Validate filename to prevent directory traversal + if ".." in filename or "/" in filename or "\\" in filename: + raise HTTPException(status_code=400, detail="Invalid filename") + + logger.warning(f"[Broll] serve_chart_preview: chart_id={chart_id}, filename={filename}, user_id={user_id}") + + broll_service = get_broll_service(user_id=user_id) expected_filename = broll_service.get_chart_preview_filename(chart_id) if filename != expected_filename: raise HTTPException(status_code=404, detail="Chart preview not found") - file_path = broll_service.get_output_path(filename) + # Use expected_filename to get the correct path + file_path = broll_service.get_output_path(expected_filename) + + logger.warning(f"[Broll] serve_chart_preview: resolved path={file_path}, exists={file_path.exists()}") if not file_path.exists(): raise HTTPException(status_code=404, detail="Chart preview not found") @@ -342,7 +363,7 @@ async def serve_chart_preview( return FileResponse( path=str(file_path), media_type="image/png", - filename=filename, + filename=expected_filename, ) diff --git a/backend/api/podcast/handlers/images.py b/backend/api/podcast/handlers/images.py index d9c50023..70722a52 100644 --- a/backend/api/podcast/handlers/images.py +++ b/backend/api/podcast/handlers/images.py @@ -17,7 +17,7 @@ from api.story_writer.utils.auth import require_authenticated_user from services.llm_providers.main_image_generation import generate_image, generate_character_image from utils.asset_tracker import save_asset_to_library from loguru import logger -from ..constants import PODCAST_IMAGES_DIR +from ..constants import get_podcast_media_dir from ..models import PodcastImageRequest, PodcastImageResponse router = APIRouter() @@ -377,14 +377,14 @@ async def generate_podcast_scene_image( user_id=user_id ) - # Save image to podcast images directory - PODCAST_IMAGES_DIR.mkdir(parents=True, exist_ok=True) + # Save image to podcast images directory (workspace-aware) + images_dir = get_podcast_media_dir("image", user_id, ensure_exists=True) # Generate filename clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in request.scene_title[:30]) unique_id = str(uuid.uuid4())[:8] image_filename = f"scene_{request.scene_id}_{clean_title}_{unique_id}.png" - image_path = PODCAST_IMAGES_DIR / image_filename + image_path = images_dir / image_filename # Save image with open(image_path, "wb") as f: @@ -470,16 +470,17 @@ async def serve_podcast_image( Query parameter is useful for HTML elements like that cannot send custom headers. Supports subdirectories like avatars/ """ - require_authenticated_user(current_user) + user_id = require_authenticated_user(current_user) # Security check: ensure path doesn't contain path traversal or absolute paths if ".." in path or path.startswith("/"): raise HTTPException(status_code=400, detail="Invalid path") - image_path = (PODCAST_IMAGES_DIR / path).resolve() + images_dir = get_podcast_media_dir("image", user_id) + image_path = (images_dir / path).resolve() - # Security check: ensure resolved path is within PODCAST_IMAGES_DIR - if not str(image_path).startswith(str(PODCAST_IMAGES_DIR)): + # Security check: ensure resolved path is within images_dir + if not str(image_path).startswith(str(images_dir)): raise HTTPException(status_code=403, detail="Access denied") if not image_path.exists(): diff --git a/backend/api/podcast/handlers/projects.py b/backend/api/podcast/handlers/projects.py index 7bdfeb07..e699fbc5 100644 --- a/backend/api/podcast/handlers/projects.py +++ b/backend/api/podcast/handlers/projects.py @@ -11,6 +11,7 @@ from typing import Optional, Dict, Any from services.database import get_db from middleware.auth_middleware import get_current_user from services.podcast_service import PodcastService +from loguru import logger from ..models import ( PodcastProjectResponse, CreateProjectRequest, @@ -106,14 +107,21 @@ async def update_project( current_user: Dict[str, Any] = Depends(get_current_user), ): """Update a podcast project state.""" + import time + start_time = time.time() + try: user_id = current_user.get("user_id") or current_user.get("id") if not user_id: logger.error(f"[Podcast] update_project: No user_id found in current_user: {current_user}") raise HTTPException(status_code=401, detail="User ID not found") - logger.warning(f"[Podcast] update_project: project_id={project_id}, user_id={user_id}") - logger.warning(f"[Podcast] update_project: request data: {request.model_dump()}") + # Get only field names being updated (not full data to avoid console flooding) + request_dict = request.model_dump(exclude_none=True) + updated_fields = list(request_dict.keys()) + + logger.warning(f"[Podcast] ===== UPDATE_PROJECT_START =====") + logger.warning(f"[Podcast] project_id={project_id}, user_id={user_id}, fields={updated_fields}") service = PodcastService(db) @@ -140,10 +148,15 @@ async def update_project( updates = request.model_dump(exclude_unset=True) project = service.update_project(user_id, project_id, **updates) + duration_ms = int((time.time() - start_time) * 1000) + logger.warning(f"[Podcast] ===== UPDATE_PROJECT_END (took {duration_ms}ms) =====") + return PodcastProjectResponse.model_validate(project) except HTTPException: raise except Exception as e: + duration_ms = int((time.time() - start_time) * 1000) + logger.error(f"[Podcast] ===== UPDATE_PROJECT_ERROR (took {duration_ms}ms): {str(e)} =====") raise HTTPException(status_code=500, detail=f"Error updating project: {str(e)}") diff --git a/backend/api/podcast/handlers/research.py b/backend/api/podcast/handlers/research.py index bfe327a6..29f4b0ae 100644 --- a/backend/api/podcast/handlers/research.py +++ b/backend/api/podcast/handlers/research.py @@ -9,6 +9,7 @@ from typing import Dict, Any, List from types import SimpleNamespace import json import re +import time from datetime import datetime, timezone from sqlalchemy.orm import Session @@ -138,10 +139,12 @@ async def podcast_research_exa( Run podcast research via Exa and then use LLM to extract deep insights. Uses Podcast Bible and Analysis context for hyper-personalization. """ + start_time = time.time() user_id = require_authenticated_user(current_user) - logger.warning(f"[Podcast Research] ========== REQUEST START ==========") - logger.warning(f"[Podcast Research] User: {user_id}, Topic: {request.topic[:80]}...") - logger.warning(f"[Podcast Research] Queries count: {len(request.queries) if request.queries else 0}") + + # Log only essential info, not full request data + logger.warning(f"[Podcast Research] ===== RESEARCH_START =====") + logger.warning(f"[Podcast Research] user={user_id}, topic='{request.topic[:50]}...', queries={len(request.queries) if request.queries else 0}") queries = [q.strip() for q in request.queries if q and q.strip()] @@ -424,6 +427,10 @@ QUALITY STANDARDS: include_avatar_phase=True, ) + duration_ms = int((time.time() - start_time) * 1000) + logger.warning(f"[Podcast Research] ===== RESEARCH_END (took {duration_ms}ms) =====") + logger.warning(f"[Podcast Research] sources={len(sources_payload)}, insights={len(key_insights)}, summary_len={len(summary)}") + return PodcastExaResearchResponse( sources=sources_payload, search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries, diff --git a/backend/api/podcast/handlers/script.py b/backend/api/podcast/handlers/script.py index ee2b274d..a321bb32 100644 --- a/backend/api/podcast/handlers/script.py +++ b/backend/api/podcast/handlers/script.py @@ -9,6 +9,7 @@ from typing import Dict, Any, Optional from pydantic import BaseModel, Field import json import re +import time from middleware.auth_middleware import get_current_user from api.story_writer.utils.auth import require_authenticated_user @@ -60,11 +61,11 @@ async def generate_podcast_script( Generate a podcast script outline (scenes + lines) using podcast-oriented prompting. """ user_id = require_authenticated_user(current_user) - logger.warning(f"[ScriptGen] ========== SCRIPT GENERATION START ==========") - logger.warning(f"[ScriptGen] Topic: {request.idea[:60]}...") - logger.warning(f"[ScriptGen] Duration: {request.duration_minutes} min, Speakers: {request.speakers}") + start_time = time.time() + logger.warning(f"[ScriptGen] ===== SCRIPT_GEN_START =====") + logger.warning(f"[ScriptGen] user={user_id}, topic='{request.idea[:50]}...', duration={request.duration_minutes}min, speakers={request.speakers}") podcast_mode = (request.podcast_mode or "video_only").strip().lower() - logger.warning(f"[ScriptGen] Has research: {bool(request.research)}, Has bible: {bool(request.bible)}, Has analysis: {bool(request.analysis)}, Mode: {podcast_mode}") + logger.warning(f"[ScriptGen] research={bool(request.research)}, bible={bool(request.bible)}, analysis={bool(request.analysis)}, mode={podcast_mode}") research_fact_cards = request.research.get("factCards", []) if request.research else [] # Build comprehensive research context for higher-quality scripts @@ -399,5 +400,8 @@ COST OPTIMIZATION: logger.warning(f"[ScriptGen] Script generated: {len(scenes)} scenes, {total_lines_output}/{total_lines_input} lines") if dropped_empty_lines > 0: logger.warning(f"[ScriptGen] Dropped {dropped_empty_lines} empty lines") + + duration_ms = int((time.time() - start_time) * 1000) + logger.warning(f"[ScriptGen] ===== SCRIPT_GEN_END (took {duration_ms}ms) =====") return PodcastScriptResponse(scenes=scenes) diff --git a/backend/api/podcast/models.py b/backend/api/podcast/models.py index 7704e186..25e141b4 100644 --- a/backend/api/podcast/models.py +++ b/backend/api/podcast/models.py @@ -223,6 +223,9 @@ class PodcastAudioRequest(BaseModel): text: str voice_id: Optional[str] = "Wise_Woman" custom_voice_id: Optional[str] = None # Voice clone ID for custom voice + use_voice_clone: Optional[bool] = False # If True, use voice clone with voice_sample_url + voice_sample_url: Optional[str] = None # URL to user's voice sample for cloning + voice_clone_engine: Optional[str] = None # Engine: "qwen3", "minimax", "cosyvoice" speed: Optional[float] = 1.0 volume: Optional[float] = 1.0 pitch: Optional[float] = 0.0 diff --git a/backend/services/podcast/broll_service.py b/backend/services/podcast/broll_service.py index c16393f0..2d0770e6 100644 --- a/backend/services/podcast/broll_service.py +++ b/backend/services/podcast/broll_service.py @@ -12,7 +12,7 @@ import uuid import os import tempfile from pathlib import Path -from typing import Dict, Any, Optional, List +from typing import Dict, Any, Optional, List, TYPE_CHECKING from loguru import logger # Import chart generators directly @@ -34,21 +34,27 @@ from services.podcast.broll_composer import ( class BrollService: """Orchestrates B-roll composition for podcast scenes.""" - def __init__(self, output_dir: Optional[str] = None): + def __init__(self, output_dir: Optional[str] = None, user_id: Optional[str] = None): """ Initialize B-roll service. Args: - output_dir: Base directory for B-roll output. Defaults to temp directory. + output_dir: Base directory for B-roll output. Defaults to workspace chart directory. + user_id: User ID for multi-tenant workspace isolation. """ if output_dir: self.output_dir = Path(output_dir) else: - self.output_dir = Path(tempfile.gettempdir()) / "broll_output" + self.output_dir = self._get_chart_dir(user_id) self.output_dir.mkdir(parents=True, exist_ok=True) logger.info(f"[BrollService] Initialized with output directory: {self.output_dir}") + def _get_chart_dir(self, user_id: Optional[str] = None) -> Path: + """Get chart directory from podcast constants (workspace-aware).""" + from api.podcast.constants import get_podcast_media_dir + return get_podcast_media_dir("chart", user_id, ensure_exists=True) + def get_output_path(self, filename: str) -> Path: """Get output path for a file.""" return self.output_dir / filename @@ -84,29 +90,91 @@ class BrollService: resolved_chart_id = chart_id or uuid.uuid4().hex[:8] out_path = str(self.get_chart_preview_path(resolved_chart_id)) + # Debug logging + logger.warning(f"[BrollService] Generating: type={chart_type}, data keys={list(chart_data.keys())}") + try: if chart_type == "bar_comparison": - make_bar_chart(chart_data, out_path, title, subtitle=subtitle) + # Accept both formats: {labels, before, after} OR {labels, values} + labels = chart_data.get("labels", []) + before = chart_data.get("before", []) + after = chart_data.get("after", []) + # If using new format (labels, values), treat as single bar chart + if not before and not after: + values = chart_data.get("values", []) + if values: + # Use original labels, set before to zeros, values go to after + before = [0] * len(labels) + after = values[:len(labels)] + # Create modified data dict with proper format for make_bar_chart + chart_data_for_render = { + "labels": labels, + "before": before, + "after": after + } + else: + chart_data_for_render = chart_data + else: + chart_data_for_render = chart_data + if not labels or (not before and not after): + logger.warning(f"[BrollService] Missing required data for bar_comparison: labels={len(labels)}, before={len(before)}, after={len(after)}") + return "" + if len(labels) != len(before) or len(labels) != len(after): + logger.warning(f"[BrollService] Data shape mismatch: labels={len(labels)}, before={len(before)}, after={len(after)}") + return "" + make_bar_chart(chart_data_for_render, out_path, title, subtitle=subtitle) elif chart_type == "bar_horizontal": + labels = chart_data.get("labels", []) + values = chart_data.get("values", []) + if not labels or not values: + logger.warning("[BrollService] Missing required data for bar_horizontal") + return "" make_horizontal_bar(chart_data, out_path, title) elif chart_type == "line_trend": + labels = chart_data.get("labels", []) + values = chart_data.get("values", []) + if not labels or not values: + logger.warning("[BrollService] Missing required data for line_trend") + return "" make_line_trend(chart_data, out_path, title) elif chart_type == "pie": - make_pie_chart(chart_data, out_path, title) - elif chart_type == "pie": + labels = chart_data.get("labels", []) + values = chart_data.get("values", []) + if not labels or not values: + logger.warning("[BrollService] Missing required data for pie") + return "" make_pie_chart(chart_data, out_path, title) elif chart_type == "stacked_bar": + labels = chart_data.get("labels", []) + segments = chart_data.get("segments", []) + if not labels or not segments: + logger.warning("[BrollService] Missing required data for stacked_bar") + return "" make_stacked_bar(chart_data, out_path, title) - elif chart_type == "bullet": + elif chart_type == "bullet" or chart_type == "bullet_points": + # Accept both: bullet_points OR labels bullet_points = chart_data.get("bullet_points", []) + # If using new format, use labels as bullet points + if not bullet_points: + bullet_points = chart_data.get("labels", []) + if not bullet_points: + labels_fallback = chart_data.get("labels", []) + if labels_fallback: + bullet_points = labels_fallback if bullet_points: make_bullet_overlay(bullet_points, out_path) else: logger.warning("[BrollService] No bullet points provided") return "" else: - logger.warning(f"[BrollService] Unknown chart type: {chart_type}") - return "" + logger.warning(f"[BrollService] Unknown chart type: {chart_type}, falling back to bar_comparison") + # Try bar_comparison as fallback + try: + make_bar_chart(chart_data, out_path, title, subtitle=subtitle) + return out_path + except Exception as fallback_err: + logger.warning(f"[BrollService] Fallback also failed: {fallback_err}") + return "" logger.info(f"[BrollService] Chart preview generated: {out_path}") return out_path @@ -254,13 +322,21 @@ class BrollService: logger.warning(f"[BrollService] Failed to remove {file}: {e}") -# Singleton instance for reuse -_broll_service_instance: Optional[BrollService] = None +# Per-user service instances for multi-tenant isolation +_broll_service_instances: Dict[str, BrollService] = {} -def get_broll_service(output_dir: Optional[str] = None) -> BrollService: - """Get or create B-roll service singleton.""" - global _broll_service_instance - if _broll_service_instance is None: - _broll_service_instance = BrollService(output_dir=output_dir) - return _broll_service_instance +def get_broll_service(output_dir: Optional[str] = None, user_id: Optional[str] = None) -> BrollService: + """ + Get or create B-roll service for the given user. + + For multi-tenant isolation, pass user_id to get user-specific directory. + """ + if output_dir: + return BrollService(output_dir=output_dir) + + # Create per-user instance based on user_id + cache_key = user_id or "default" + if cache_key not in _broll_service_instances: + _broll_service_instances[cache_key] = BrollService(user_id=user_id) + return _broll_service_instances[cache_key] diff --git a/backend/services/podcast_service.py b/backend/services/podcast_service.py index 8ff679d3..38386dfe 100644 --- a/backend/services/podcast_service.py +++ b/backend/services/podcast_service.py @@ -86,8 +86,8 @@ class PodcastService: ) -> Optional[PodcastProject]: """Update project fields.""" from loguru import logger - logger.warning(f"[PodcastService] update_project: user_id={user_id}, project_id={project_id}") - logger.warning(f"[PodcastService] update_project: updates={updates}") + updated_fields = list(updates.keys()) if isinstance(updates, dict) else [] + logger.warning(f"[PodcastService] update_project: user_id={user_id}, project_id={project_id}, fields={updated_fields}") project = self.get_project(user_id, project_id) if not project: diff --git a/frontend/src/api/brandAssets.ts b/frontend/src/api/brandAssets.ts index c205b3f3..190e40fb 100644 --- a/frontend/src/api/brandAssets.ts +++ b/frontend/src/api/brandAssets.ts @@ -17,6 +17,7 @@ export interface VoiceCloneResponse { voice_name?: string; preview_audio_url?: string; asset_id?: number; + engine?: string; message?: string; error?: string; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3f8de8d7..b5884311 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -461,9 +461,11 @@ aiApiClient.interceptors.response.use( } if (error.response.status >= 500) { - openBackendCooldown(`http_${error.response.status}`); + // Do NOT trigger cooldown for application-level 500 errors (e.g. TTS failures). + // Cooldown should only block for network connectivity issues (handled above). + // Application 500s should be handled by individual callers. return Promise.reject( - new ConnectionError('Backend server is experiencing issues. Please try again later.') + new ConnectionError(`Server error ${error.response.status}: ${error.response.statusText || 'Internal Server Error'}`) ); } diff --git a/frontend/src/components/PodcastMaker/CreateModal.tsx b/frontend/src/components/PodcastMaker/CreateModal.tsx index 32ab118b..92dee699 100644 --- a/frontend/src/components/PodcastMaker/CreateModal.tsx +++ b/frontend/src/components/PodcastMaker/CreateModal.tsx @@ -5,7 +5,9 @@ import { useSubscription } from "../../contexts/SubscriptionContext"; import { podcastApi } from "../../services/podcastApi"; import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl"; import { getLatestBrandAvatar } from "../../api/brandAssets"; -import { VoiceSelector } from "../shared/VoiceSelector"; +import { VoiceSelector, VOICE_CLONE_ID } from "../shared/VoiceSelector"; +import { getLatestVoiceClone } from "../../api/brandAssets"; +import { setCachedVoiceCloneInfo } from "../../services/podcastApi"; // Imported Components import { TopicUrlInput, TOPIC_PLACEHOLDERS } from "./CreateStep/TopicUrlInput"; @@ -316,9 +318,43 @@ export const CreateModal: React.FC = ({ onCreate, open, defaul } // Include selected voice in knobs - const finalKnobs = { + // If voice clone is selected, include voice clone metadata + const isVoiceClone = selectedVoiceId === VOICE_CLONE_ID || knobs.custom_voice_id === selectedVoiceId; + + let voiceSampleUrl: string | undefined; + let voiceCloneEngine: string | undefined; + let customVoiceId: string | undefined; + + if (isVoiceClone) { + try { + const voiceCloneInfo = await getLatestVoiceClone(); + if (voiceCloneInfo?.success && voiceCloneInfo.custom_voice_id) { + customVoiceId = voiceCloneInfo.custom_voice_id; + voiceSampleUrl = voiceCloneInfo.preview_audio_url; + voiceCloneEngine = voiceCloneInfo.engine || "qwen3"; + // Cache for reuse across scenes + setCachedVoiceCloneInfo({ + customVoiceId, + voiceSampleUrl, + engine: voiceCloneEngine, + isVoiceClone: true, + }); + } + } catch (e) { + console.warn("[CreateModal] Could not fetch voice clone info:", e); + } + } else { + // Clear cache if system voice selected + setCachedVoiceCloneInfo({ isVoiceClone: false }); + } + + const finalKnobs: Knobs = { ...knobs, - voice_id: selectedVoiceId, + voice_id: isVoiceClone ? "Wise_Woman" : selectedVoiceId, + custom_voice_id: customVoiceId, + is_voice_clone: isVoiceClone, + voice_sample_url: voiceSampleUrl, + voice_clone_engine: voiceCloneEngine, }; try { diff --git a/frontend/src/components/PodcastMaker/CreateStep/CreateActions.tsx b/frontend/src/components/PodcastMaker/CreateStep/CreateActions.tsx index 4be835db..cac53f28 100644 --- a/frontend/src/components/PodcastMaker/CreateStep/CreateActions.tsx +++ b/frontend/src/components/PodcastMaker/CreateStep/CreateActions.tsx @@ -384,6 +384,11 @@ export const CreateActions: React.FC = ({ reset, submit, can const [showAnalysisModal, setShowAnalysisModal] = useState(false); const [analysisStarted, setAnalysisStarted] = useState(false); const [progressIndex, setProgressIndex] = useState(0); + + // Track previous isSubmitting value at component level (not inside effect) + const prevIsSubmittingRef = useRef(isSubmitting); + const [analysisCompleteRef, setAnalysisCompleteRef] = useState(false); + const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -393,10 +398,6 @@ export const CreateActions: React.FC = ({ reset, submit, can }, []); // Close modal only AFTER analysis fully completes (wait for project/analysis to be set) - // Use a ref to track previous isSubmitting to detect the transition from true to false - const prevIsSubmittingRef = useRef(isSubmitting); - const [analysisCompleteRef, setAnalysisCompleteRef] = useState(false); - useEffect(() => { // Track if analysis transitioned from true to false (completed) const wasSubmitting = prevIsSubmittingRef.current; @@ -424,7 +425,7 @@ export const CreateActions: React.FC = ({ reset, submit, can if (error && showAnalysisModal) { console.warn('[CreateActions] Error detected — keeping modal open:', error); } - }, [isSubmitting, showAnalysisModal, analysisStarted, onAnnouncementClear, error, analysisCompleteRef]); + }, [isSubmitting, showAnalysisModal, analysisStarted, onAnnouncementClear, error]); // Sequential progress - increment every few seconds useEffect(() => { diff --git a/frontend/src/components/PodcastMaker/PodcastDashboard.tsx b/frontend/src/components/PodcastMaker/PodcastDashboard.tsx index b14c3f22..e6951374 100644 --- a/frontend/src/components/PodcastMaker/PodcastDashboard.tsx +++ b/frontend/src/components/PodcastMaker/PodcastDashboard.tsx @@ -20,6 +20,7 @@ import { DEFAULT_KNOBS, getStepLabel, } from "./PodcastDashboard/index"; +import { ScriptGenerationProgressView } from "./PodcastDashboard/ScriptGenerationProgressView"; const PodcastDashboard: React.FC = () => { useEffect(() => { @@ -400,6 +401,69 @@ const PodcastDashboard: React.FC = () => { + + {/* Script Generation Progress Modal */} + { + // Only allow closing if NOT generating and generation hasn't started + if (!workflow.isGeneratingScript && !workflow.scriptGenStarted) { + workflow.setShowScriptGenModal(false); + } + }} + maxWidth="sm" + fullWidth + PaperProps={{ + sx: { + background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)", + border: "1px solid rgba(52, 211, 153, 0.3)", + borderRadius: 3, + }, + }} + > + + {workflow.isGeneratingScript ? ( + + + Generating Your Script + + ) : ( + + Script Complete + + )} + + + + + + {workflow.isGeneratingScript ? ( + + ) : ( + + )} + + ); }; diff --git a/frontend/src/components/PodcastMaker/PodcastDashboard/ResearchSummary.tsx b/frontend/src/components/PodcastMaker/PodcastDashboard/ResearchSummary.tsx index bbe80182..9277e490 100644 --- a/frontend/src/components/PodcastMaker/PodcastDashboard/ResearchSummary.tsx +++ b/frontend/src/components/PodcastMaker/PodcastDashboard/ResearchSummary.tsx @@ -1,5 +1,5 @@ -import React, { useMemo, useCallback } from "react"; -import { Stack, Typography, Chip, Divider, Box, alpha, Paper, CircularProgress } from "@mui/material"; +import React, { useCallback } from "react"; +import { Stack, Typography, Chip, Divider, Box, alpha, Paper, CircularProgress, Tooltip } from "@mui/material"; import { Insights as InsightsIcon, Search as SearchIcon, @@ -7,8 +7,9 @@ import { Article as ArticleIcon, AutoAwesome as AutoAwesomeIcon, ArrowForward as ArrowForwardIcon, + HelpOutline as HelpOutlineIcon, } from "@mui/icons-material"; -import { Research, ResearchInsight } from "../types"; +import { Research, ResearchInsight, Fact } from "../types"; import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui"; import { FactCard } from "../FactCard"; import { TextToSpeechButton } from "../../shared/TextToSpeechButton"; @@ -26,6 +27,27 @@ export const ResearchSummary: React.FC = ({ onGenerateScript, isGeneratingScript = false, }) => { + const getSourceFact = (idx: number): Fact | undefined => { + const factCards = research.factCards || []; + return factCards.find(f => f.id === `source-${idx}`); + }; + + // Strip markdown for text-to-speech + const stripMarkdown = (text: string): string => { + if (!text) return ''; + return text + .replace(/#{1,6}\s+/g, '') // Headers + .replace(/\*\*(.*?)\*\*/g, '$1') // Bold + .replace(/\*(.*?)\*/g, '$1') // Italic + .replace(/\[(.*?)\]\(.*?\)/g, '$1') // Links + .replace(/`{1,3}(.*?)`{1,3}/g, '$1') // Code + .replace(/^\s*[-*+]\s+/gm, '') // List items + .replace(/^\s*\d+\.\s+/gm, '') // Numbered list + .replace(/\n{2,}/g, '. ') // Multiple newlines to periods + .replace(/\n/g, ' ') // Single newlines to spaces + .trim(); + }; + // Simple markdown-to-HTML converter const renderMarkdown = useCallback((text: string) => { if (!text) return null; @@ -150,7 +172,7 @@ export const ResearchSummary: React.FC = ({ Executive Summary - + = ({ borderRadius: 2, }} > - - + + {insight.title} + {insight.source_indices && insight.source_indices.length > 0 && ( - {insight.source_indices.map(sIdx => ( - - ))} + {insight.source_indices.map(sIdx => { + const source = research.sources?.[sIdx - 1]; + const fact = getSourceFact(sIdx); + return ( + + {fact ? ( + + + Source S{sIdx} + + + "{fact.quote}" + + {fact.author && ( + + {fact.author} + + )} + + ) : source ? ( + + + Source S{sIdx} + + + {source.title} + + + ) : ( + No source details + )} + + } + placement="right" + arrow + followCursor + > + + + ); + })} )} @@ -259,17 +328,10 @@ export const ResearchSummary: React.FC = ({ {/* Expert Quotes */} - - NEW - Expert Quotes + + + {research.expertQuotes && research.expertQuotes.length > 0 ? ( @@ -286,38 +348,69 @@ export const ResearchSummary: React.FC = ({ "{quote.quote}" - {sourceUrl ? ( + + {(() => { + const source = research.sources?.[quote.source_index - 1]; + const fact = getSourceFact(quote.source_index); + if (fact) { + return ( + + + Source S{quote.source_index} + + + "{fact.quote}" + + {fact.author && ( + + {fact.author} + + )} + + ); + } + if (source) { + return ( + + + Source S{quote.source_index} + + + {source.title} + + + ); + } + return No source details; + })()} + + } + placement="right" + arrow + followCursor + > - ) : ( - - )} + ); @@ -333,17 +426,10 @@ export const ResearchSummary: React.FC = ({ {/* Listener CTAs */} - - NEW - Listener CTAs + + + {research.listenerCta && research.listenerCta.length > 0 ? ( @@ -370,17 +456,10 @@ export const ResearchSummary: React.FC = ({ {/* Mapped Angles */} - - NEW - Mapped Angles + + + {research.mappedAngles && research.mappedAngles.length > 0 ? ( @@ -405,54 +484,7 @@ export const ResearchSummary: React.FC = ({ No mapped angles available yet. )} - - - {/* Listener CTAs */} - - - Listener CTAs - - {research.listenerCta && research.listenerCta.length > 0 ? ( - - {research.listenerCta.slice(0, 4).map((cta, idx) => ( - - - {cta} - - - ))} - - ) : ( - - No listener CTAs suggested yet. - - )} - - - {/* Mapped Angles */} - - - Mapped Angles - - {research.mappedAngles && research.mappedAngles.length > 0 ? ( - - {research.mappedAngles.slice(0, 4).map((angle, idx) => ( - - - {angle.title || `Angle ${idx + 1}`} - - - {angle.why || "No rationale provided."} - - - ))} - - ) : ( - - No mapped angles available yet. - - )} - + {/* Search Queries Used */} {research.searchQueries && research.searchQueries.length > 0 && ( diff --git a/frontend/src/components/PodcastMaker/PodcastDashboard/ScriptGenerationProgressView.tsx b/frontend/src/components/PodcastMaker/PodcastDashboard/ScriptGenerationProgressView.tsx new file mode 100644 index 00000000..275f51ea --- /dev/null +++ b/frontend/src/components/PodcastMaker/PodcastDashboard/ScriptGenerationProgressView.tsx @@ -0,0 +1,369 @@ +import React from "react"; +import { + Stack, + Typography, + CircularProgress, + LinearProgress, + Box, + Divider, + useMediaQuery, + useTheme, +} from "@mui/material"; +import { + AutoAwesome as AutoAwesomeIcon, + CheckCircle as CheckCircleIcon, + Psychology as PsychologyIcon, + Insights as InsightsIcon, + Article as ArticleIcon, + Edit as EditIcon, + VolumeUp as VolumeUpIcon, + VideoLibrary as VideoLibraryIcon, + Lightbulb as LightbulbIcon, + Search as SearchIcon, + FactCheck as FactCheckIcon, + School as SchoolIcon, + Update as UpdateIcon, + Bolt as BoltIcon, + TheaterComedy as TheaterComedyIcon, + RecordVoiceOver as RecordVoiceOverIcon, + FormatListBulleted as FormatListBulletedIcon, + Chat as ChatIcon, +} from "@mui/icons-material"; + +const SCRIPT_GENERATION_MESSAGES = [ + { title: "Analyzing Research Data", message: "Extracting key insights, facts, and statistics from your research..." }, + { title: "Building Structure", message: "Creating podcast structure with scenes and segments..." }, + { title: "Writing Dialogue", message: "Writing AI-powered dialogue personalized to your audience..." }, + { title: "Finalizing Script", message: "Finalizing scenes with proper pacing for text-to-speech..." }, +]; + +const SCRIPT_BENEFITS = [ + { + title: "Research-Grounded Content", + description: "Your script cites real facts and sources from the research phase", + icon: , + color: "#10b981", + }, + { + title: "Audience-Targeted", + description: "Dialogue written for your specific target audience", + icon: , + color: "#a78bfa", + }, + { + title: "Optimized for TTS", + description: "Proper pacing and hints for natural text-to-speech output", + icon: , + color: "#60a5fa", + }, +]; + +const WHAT_IS_SCENE = { + title: "What is a Scene?", + definition: "A scene is a single section of your podcast episode. It contains dialogue from presenters and optional chart data for visuals.", + icon: , + color: "#34d399", +}; + +const PODCAST_CREATION_JOURNEY = [ + { + phase: "Analyze", + icon: , + color: "#a78bfa", + description: "AI understands your topic and target audience", + benefit: "Identifies key themes and angles" + }, + { + phase: "Research", + icon: , + color: "#60a5fa", + description: "Gathers facts, statistics, and latest insights", + benefit: "Evidence-based content" + }, + { + phase: "Write Script", + icon: , + color: "#34d399", + description: "Transforms research into structured script", + benefit: "Factual, engaging content", + isCurrent: true, + }, + { + phase: "Final Render", + icon: , + color: "#ef4444", + description: "Your ready-to-publish podcast episode", + benefit: "Professional output" + }, +]; + +const SCRIPT_EDITOR_PREVIEW = [ + { label: "Edit Dialogue", description: "Click any line to modify the text", icon: }, + { label: "Approve Scenes", description: "Mark scenes as ready for rendering", icon: }, + { label: "Regenerate", description: "Regenerate specific scenes if needed", icon: }, + { label: "Add Charts", description: "Charts auto-generated from research facts", icon: }, +]; + +interface ScriptGenerationProgressViewProps { + currentMessage?: string; + progressIndex: number; + idea?: string; + analysis?: any; + research?: any; + sourceCount?: number; +} + +export const ScriptGenerationProgressView: React.FC = ({ + currentMessage, + progressIndex, + idea, + analysis, + research, + sourceCount, +}) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const clampedIndex = Math.min(progressIndex, SCRIPT_GENERATION_MESSAGES.length - 1); + + const audience = analysis?.audience || "General audience"; + const keywords = analysis?.topKeywords?.slice(0, 5) || []; + const outlineTitle = analysis?.suggestedOutlines?.[0]?.title || "Not specified"; + const factCards = research?.factCards || []; + const keyInsights = research?.keyInsights || []; + const searchQueries = research?.searchQueries || []; + + return ( + + {/* Current Status */} + + + + + + + + + + {SCRIPT_GENERATION_MESSAGES[clampedIndex].title} + + + + {currentMessage || SCRIPT_GENERATION_MESSAGES[clampedIndex].message} + + + {currentMessage && ( + + {currentMessage} + + )} + + + + + Step {clampedIndex + 1} of {SCRIPT_GENERATION_MESSAGES.length} + + + + + + {/* How Prior Phases Are Used */} + + + How We're Personalizing Your Script + + + + {/* Analysis Context */} + + + + + + + + From Analyze Phase + + + Audience: {audience} + + {keywords.length > 0 && ( + + Keywords: {keywords.join(", ")} + + )} + + + + + {/* Research Context */} + + + + + + + + From Research Phase + + + {factCards.length} facts, {keyInsights.length} insights, {sourceCount || 0} sources + + {searchQueries.length > 0 && ( + + From {searchQueries.length} research queries + + )} + + + + + + + + + {/* What is a Scene */} + + + {WHAT_IS_SCENE.title} + + + + + {React.cloneElement(WHAT_IS_SCENE.icon, { sx: { color: WHAT_IS_SCENE.color, fontSize: 16 } })} + + + + {WHAT_IS_SCENE.definition} + + + + + + + + + {/* Sequential Progress Steps */} + + + Script Generation Progress + + + {SCRIPT_GENERATION_MESSAGES.map((msg, idx) => { + const isCompleted = idx < clampedIndex; + const isCurrent = idx === clampedIndex; + return ( + + + {isCompleted ? ( + + ) : isCurrent ? ( + + ) : ( + + )} + + + + {msg.title} + + + + ); + })} + + + + + + {/* What to Expect in Script Editor */} + + + What's Next: Script Editor + + + {SCRIPT_EDITOR_PREVIEW.map((item, idx) => ( + + + {React.cloneElement(item.icon, { sx: { fontSize: 18 } })} + + {item.label} + + + {item.description} + + + + ))} + + + + + + {/* Journey Overview */} + + + Your Podcast Journey + + + {PODCAST_CREATION_JOURNEY.map((phase, idx) => ( + + + + {React.cloneElement(phase.icon, { sx: { color: phase.isCurrent ? "#34d399" : phase.color, fontSize: 16 } })} + + + + {phase.phase} {phase.isCurrent && "◀ In Progress"} + + + {phase.description} + + + ✓ {phase.benefit} + + + + + ))} + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/PodcastMaker/PodcastDashboard/usePodcastWorkflow.ts b/frontend/src/components/PodcastMaker/PodcastDashboard/usePodcastWorkflow.ts index 5491e0cf..ca57144b 100644 --- a/frontend/src/components/PodcastMaker/PodcastDashboard/usePodcastWorkflow.ts +++ b/frontend/src/components/PodcastMaker/PodcastDashboard/usePodcastWorkflow.ts @@ -61,6 +61,11 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow const [preflightOperationName, setPreflightOperationName] = useState(""); const [showDuplicateDialog, setShowDuplicateDialog] = useState(false); const [duplicateProjectInfo, setDuplicateProjectInfo] = useState<{projectId: string; idea: string}>({ projectId: "", idea: "" }); + + // Script Generation Modal State + const [showScriptGenModal, setShowScriptGenModal] = useState(false); + const [scriptGenStarted, setScriptGenStarted] = useState(false); + const [scriptGenProgressIndex, setScriptGenProgressIndex] = useState(0); const budgetTracking = useBudgetTracking(budgetCap || 50); const preflightCheck = usePreflightCheck({ @@ -94,6 +99,47 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow return undefined; }, [announcement]); + const prevIsGeneratingScriptRef = useRef(false); + + // Sequential progress for script generation modal + useEffect(() => { + if (!showScriptGenModal || !scriptGenStarted) { + setScriptGenProgressIndex(0); + return; + } + + const interval = setInterval(() => { + setScriptGenProgressIndex((prev) => { + if (prev < 3) { // 4 steps total (0-3) + return prev + 1; + } + return prev; + }); + }, 3000); + + return () => clearInterval(interval); + }, [showScriptGenModal, scriptGenStarted]); + + // Handle modal close when script generation completes + useEffect(() => { + const wasSubmitting = prevIsGeneratingScriptRef.current; + const nowNotSubmitting = !isGeneratingScript; + + // Only close modal if: + // 1. Modal is still shown + // 2. scriptGenStarted is true + // 3. isGeneratingScript transitioned from true to false + // 4. AND we're not showing an error (scriptData is set on success) + if (showScriptGenModal && scriptGenStarted && wasSubmitting && nowNotSubmitting && !announcement.includes("failed")) { + setTimeout(() => { + setShowScriptGenModal(false); + }, 500); + } + + // Update ref for next render + prevIsGeneratingScriptRef.current = isGeneratingScript; + }, [isGeneratingScript, showScriptGenModal, scriptGenStarted, announcement]); + const handleCreate = useCallback(async (payload: CreateProjectPayload, feedback?: string) => { if (isAnalyzing) return; setResearch(null); @@ -327,20 +373,12 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow return; } - setPreflightOperationName("Research"); + // Note: Preflight is handled inside podcastApi.runExaResearch (ensurePreflight) + // No need to call it twice here + const approvedQueries = queries.filter((q) => selectedQueries.has(q.id)); console.log('[Research] User selected queries:', Array.from(selectedQueries)); console.log('[Research] Filtered approvedQueries for API:', approvedQueries.map(q => q.query)); - const preflightResult = await preflightCheck.check({ - provider: researchProvider === "exa" ? "exa" : "gemini", - operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding", - tokens_requested: researchProvider === "exa" ? 0 : 1200, - actual_provider_name: researchProvider || "exa", - }); - - if (!preflightResult.can_proceed) { - return; - } try { setIsResearching(true); @@ -395,45 +433,44 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow } finally { setIsResearching(false); } - }, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setEstimate, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]); + }, [isResearching, project, selectedQueries, queries, researchProvider, analysis, setResearch, setRawResearch, setEstimate, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]); // Add a ref to track if we're currently generating to prevent double calls const isGeneratingRef = useRef(false); const handleGenerateScript = useCallback(async () => { - // Guard against double calls - if (isGeneratingRef.current) { + // CRITICAL: Guard against double calls - set IMMEDIATELY to prevent concurrent clicks + if (isGeneratingRef.current || isGeneratingScript) { console.log('[ScriptGen] Already generating, skipping duplicate call'); return; } - if (showScriptEditor) return; + // Prevent if script already exists or render phase started + if (showScriptEditor || projectState.scriptData) { + console.log('[ScriptGen] Script already exists, skipping'); + return; + } + if (!project || !research) { setAnnouncement("Project or research missing — cannot generate script"); return; } - // Mark as generating immediately (both ref and state) + // Mark as generating immediately BEFORE any async calls (both ref and state) isGeneratingRef.current = true; setIsGeneratingScript(true); + + // Show modal IMMEDIATELY to prevent duplicate clicks + setShowScriptGenModal(true); + setScriptGenStarted(true); + setScriptGenProgressIndex(0); + console.log('[ScriptGen] Modal shown, generating ref set'); - setPreflightOperationName("Script Generation"); - const preflightResult = await preflightCheck.check({ - provider: "gemini", - operation_type: "script_generation", - tokens_requested: 2000, - actual_provider_name: "gemini", - }); - - if (!preflightResult.can_proceed) { - isGeneratingRef.current = false; // Reset on preflight failure - setIsGeneratingScript(false); // Reset loading state on preflight failure - return; - } - + // Note: Preflight is also called inside podcastApi.generateScript (ensurePreflight) + // No need to call it twice - the API layer handles it + setScriptData(null); setShowRenderQueue(false); - setShowScriptEditor(true); setAnnouncement("Generating script with AI... Creating scenes and dialogue based on your research..."); try { @@ -464,6 +501,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow console.log('[ScriptGen] Script generated:', { sceneCount: result.scenes?.length }); setScriptData(result); + setShowScriptEditor(true); // Open editor after successful generation setIsGeneratingScript(false); setAnnouncement("Script generated! Review and edit your scenes below."); } catch (error) { @@ -472,7 +510,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow } finally { isGeneratingRef.current = false; // Reset when done } - }, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible, analysis]) + }, [showScriptEditor, project, research, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible, analysis, setShowScriptGenModal, scriptGenStarted, setScriptGenProgressIndex, isGeneratingScript, projectState.scriptData, currentStep]) const handleProceedToRendering = useCallback((script: Script) => { // Clear media cache for all scenes before proceeding to remove old blobs @@ -608,6 +646,11 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow duplicateProjectInfo, activeStep, canGenerateScript, + // Script Generation Modal + showScriptGenModal, + setShowScriptGenModal, + scriptGenStarted, + scriptGenProgressIndex, // Handlers handleCreate, handleRegenerate, diff --git a/frontend/src/components/PodcastMaker/RenderQueue/useRenderQueue.ts b/frontend/src/components/PodcastMaker/RenderQueue/useRenderQueue.ts index 9c081b0b..37afb773 100644 --- a/frontend/src/components/PodcastMaker/RenderQueue/useRenderQueue.ts +++ b/frontend/src/components/PodcastMaker/RenderQueue/useRenderQueue.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { Script, Knobs, Job, RenderJobResult, TaskStatus, VideoGenerationSettings } from "../types"; -import { podcastApi } from "../../../services/podcastApi"; +import { podcastApi, getCachedVoiceCloneInfo } from "../../../services/podcastApi"; interface UseRenderQueueProps { script: Script; @@ -427,9 +427,14 @@ export const useRenderQueue = ({ }); try { + const cachedClone = getCachedVoiceCloneInfo(); const result: RenderJobResult = await podcastApi.renderSceneAudio({ scene, - voiceId: "Wise_Woman", + voiceId: knobs.voice_id || "Wise_Woman", + customVoiceId: knobs.custom_voice_id || cachedClone?.customVoiceId, + useVoiceClone: knobs.is_voice_clone || cachedClone?.isVoiceClone || false, + voiceSampleUrl: knobs.voice_sample_url || cachedClone?.voiceSampleUrl || undefined, + voiceCloneEngine: knobs.voice_clone_engine || cachedClone?.engine || undefined, emotion: scene.emotion || getSceneVoiceEmotion(knobs), speed: knobs.voice_speed, }); diff --git a/frontend/src/components/PodcastMaker/ScriptEditor/AudioRegenerateModal.tsx b/frontend/src/components/PodcastMaker/ScriptEditor/AudioRegenerateModal.tsx index ff00b7a7..1f66d076 100644 --- a/frontend/src/components/PodcastMaker/ScriptEditor/AudioRegenerateModal.tsx +++ b/frontend/src/components/PodcastMaker/ScriptEditor/AudioRegenerateModal.tsx @@ -26,6 +26,9 @@ import { VoiceSelector } from "../../shared/VoiceSelector"; export type AudioGenerationSettings = { voiceId: string; customVoiceId?: string; + useVoiceClone?: boolean; + voiceSampleUrl?: string; + voiceCloneEngine?: string; speed: number; volume: number; pitch: number; diff --git a/frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx b/frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx index 19d4493f..3957e9f3 100644 --- a/frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx +++ b/frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx @@ -16,7 +16,7 @@ import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui"; import { LineEditor } from "./LineEditor"; import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerateModal"; import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal"; -import { podcastApi } from "../../../services/podcastApi"; +import { podcastApi, getCachedVoiceCloneInfo } from "../../../services/podcastApi"; import { aiApiClient } from "../../../api/client"; import { getCachedMedia, setCachedMedia } from "../../../utils/mediaCache"; @@ -68,6 +68,9 @@ export const SceneEditor: React.FC = ({ const [audioSettings, setAudioSettings] = useState({ voiceId: knobs.voice_id || "Wise_Woman", customVoiceId: knobs.custom_voice_id || undefined, + useVoiceClone: knobs.is_voice_clone || false, + voiceSampleUrl: knobs.voice_sample_url || undefined, + voiceCloneEngine: knobs.voice_clone_engine || undefined, speed: knobs.voice_speed ?? 1.0, volume: 1.0, pitch: 0.0, @@ -308,10 +311,14 @@ export const SceneEditor: React.FC = ({ // Generate audio const effectiveSettings = settings || audioSettings; + const cachedClone = getCachedVoiceCloneInfo(); const result = await podcastApi.renderSceneAudio({ scene: currentScene, voiceId: effectiveSettings.voiceId || knobs.voice_id || "Wise_Woman", - customVoiceId: effectiveSettings.customVoiceId || knobs.custom_voice_id, + customVoiceId: effectiveSettings.customVoiceId || knobs.custom_voice_id || cachedClone?.customVoiceId, + useVoiceClone: effectiveSettings.useVoiceClone || knobs.is_voice_clone || cachedClone?.isVoiceClone || false, + voiceSampleUrl: effectiveSettings.voiceSampleUrl || knobs.voice_sample_url || cachedClone?.voiceSampleUrl || undefined, + voiceCloneEngine: effectiveSettings.voiceCloneEngine || knobs.voice_clone_engine || cachedClone?.engine || undefined, emotion: effectiveSettings.emotion || scene.emotion || knobs.voice_emotion || "neutral", speed: effectiveSettings.speed ?? knobs.voice_speed ?? 1.0, volume: effectiveSettings.volume ?? 1.0, diff --git a/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx b/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx index e134a737..0aceaaf1 100644 --- a/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx +++ b/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx @@ -7,8 +7,9 @@ import { podcastApi } from "../../../services/podcastApi"; import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui"; import { SceneEditor } from "./SceneEditor"; import { InlineAudioPlayer } from "../InlineAudioPlayer"; -import { aiApiClient } from "../../../api/client"; +import { aiApiClient, getApiUrl } from "../../../api/client"; import { BrollInfoPanel } from "./parts/BrollInfoPanel"; +import { ScriptEditorProvider } from "./ScriptEditorContext"; interface ScriptEditorProps { projectId: string; @@ -75,49 +76,8 @@ export const ScriptEditor: React.FC = ({ } }, [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, - podcastMode, - analysis, - outline, - }) - .then((res) => { - if (mounted) { - setScript(res); - emitScriptChange(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, podcastMode, analysis, outline, emitScriptChange, onError, script]); + // Note: Script generation is now handled by ScriptEditorProvider + // to ensure BrollInfoPanel and other child components have access to context const updateScene = (updated: Scene) => { // Use functional update to ensure we're working with latest state @@ -309,14 +269,20 @@ export const ScriptEditor: React.FC = ({ chart_type: scene.chart_data.type || "bar_comparison", title: scene.title, }); + console.log(`[ChartPreview] Scene ${scene.id}: type=${scene.chart_data.type || 'bar_comparison'}, data=`, scene.chart_data); + + const toFullUrl = (url: string) => { + if (/^https?:\/\//i.test(url)) return url; + return `${getApiUrl()}${url.startsWith("/") ? url : `/${url}`}`; + }; return { ...scene, - broll_preview_url: result.preview_url, + broll_preview_url: toFullUrl(result.preview_url), chart_id: result.chart_id, }; } catch (error) { - console.error(`Failed to generate chart preview for scene ${scene.id}:`, error); + console.error(`[ChartPreview] Failed for scene ${scene.id}:`, error); return scene; } }) @@ -379,11 +345,28 @@ export const ScriptEditor: React.FC = ({ }, [script, emitScriptChange]); return ( - - - }> - Back to Research - + { + setScript(s); + onScriptChange(s); + }} + onError={onError} + > + + + }> + Back to Research + = ({ )} + ); }; diff --git a/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditorContext.tsx b/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditorContext.tsx index 16b29451..26e5e18f 100644 --- a/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditorContext.tsx +++ b/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditorContext.tsx @@ -320,6 +320,9 @@ export const ScriptEditorProvider: React.FC = ({ scenes: sceneData, voiceId: knobs.voice_id, customVoiceId: knobs.custom_voice_id, + useVoiceClone: knobs.is_voice_clone, + voiceSampleUrl: knobs.voice_sample_url, + voiceCloneEngine: knobs.voice_clone_engine, speed: knobs.voice_speed, emotion: knobs.voice_emotion, englishNormalization: true, diff --git a/frontend/src/components/PodcastMaker/ScriptEditor/parts/BrollInfoPanel.tsx b/frontend/src/components/PodcastMaker/ScriptEditor/parts/BrollInfoPanel.tsx index 3a0d4bd7..e3fd2ffb 100644 --- a/frontend/src/components/PodcastMaker/ScriptEditor/parts/BrollInfoPanel.tsx +++ b/frontend/src/components/PodcastMaker/ScriptEditor/parts/BrollInfoPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { Stack, Box, Typography, Alert, Paper, Button, CircularProgress, Chip } from "@mui/material"; -import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon } from "@mui/icons-material"; +import { Stack, Box, Typography, Paper, Button, CircularProgress, Chip, IconButton, Tooltip } from "@mui/material"; +import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon, Fullscreen as FullscreenIcon, Visibility as VisibilityIcon } from "@mui/icons-material"; import { useScriptEditor } from "../ScriptEditorContext"; import { Script } from "../../types"; @@ -42,119 +42,246 @@ export const BrollInfoPanel: React.FC = (props) => { return ( - - - - - - - B-Roll Charts - - - Programmatic charts extracted from research data - - - + + + + + + + + B-Roll Charts + + + {resolvedScenesWithCharts} chart{resolvedScenesWithCharts !== 1 ? 's' : ''} for visual storytelling + + + {hasChartData && ( - 1 ? 's' : ''} with charts`} + )} - {!hasChartData ? ( - - - No charts detected. If your research contains statistics or metrics, the script generation will automatically extract chart data for B-roll visualization. - - - ) : ( - - - Your script contains {scenesWithData.length} scene(s) with chart data. - Click below to generate chart previews for the Write phase. - - - - - - - {scenesWithData.map((scene) => ( - - - - {scene.title} - - - {scene.chart_data?.type || "chart"} • {scene.chart_data?.labels?.length || 0} data points - - - - - {resolvedGeneratingChartId === scene.id ? ( - - ) : scene.broll_preview_url ? ( - <> - + {scenesWithData.map((scene) => { + const chartData = scene.chart_data; + const hasPreview = !!scene.broll_preview_url; + + return ( + + {/* Thumbnail */} + + {resolvedGeneratingChartId === scene.id ? ( + + ) : hasPreview && scene.broll_preview_url ? ( + window.open(scene.broll_preview_url, '_blank')} /> - - - - ) : null} - - - ))} + + + + + + ); + })} + ) : ( + + + + No chart data yet. Add chart data to scenes to generate B-roll visuals. + + )} ); -}; +}; \ No newline at end of file diff --git a/frontend/src/components/PodcastMaker/types.ts b/frontend/src/components/PodcastMaker/types.ts index f09c4a82..4fbd1f47 100644 --- a/frontend/src/components/PodcastMaker/types.ts +++ b/frontend/src/components/PodcastMaker/types.ts @@ -3,6 +3,9 @@ export type Knobs = { voice_speed: number; voice_id: string; custom_voice_id?: string; + is_voice_clone?: boolean; + voice_sample_url?: string; + voice_clone_engine?: string; resolution: string; scene_length_target: number; sample_rate: number; diff --git a/frontend/src/components/PodcastMaker/ui/GlassyCard.tsx b/frontend/src/components/PodcastMaker/ui/GlassyCard.tsx index 7ca4ff1f..f3d041da 100644 --- a/frontend/src/components/PodcastMaker/ui/GlassyCard.tsx +++ b/frontend/src/components/PodcastMaker/ui/GlassyCard.tsx @@ -5,12 +5,40 @@ interface GlassyCardProps { children?: React.ReactNode; sx?: SxProps; onClick?: () => void; - [key: string]: any; // Allow other props for framer-motion + // Allow motion props (framer-motion) - they'll be filtered out to avoid DOM warnings + whileHover?: any; + whileTap?: any; + initial?: any; + animate?: any; + exit?: any; + transition?: any; + variants?: any; + layout?: any; + layoutId?: any; + className?: string; + 'aria-label'?: string; } export const GlassyCard: React.FC = ({ children, sx, ...props }) => { + // Filter out motion props to avoid DOM warnings - these won't work with MUI Paper anyway + const { + whileHover, + whileTap, + initial, + animate, + exit, + transition, + variants, + layout, + layoutId, + className, + 'aria-label': ariaLabel, + ...filteredProps + } = props; return ( = ({ children, sx, ...props } }, ...sx }} - {...props} + {...filteredProps} > {children} diff --git a/frontend/src/components/shared/AudioSettingsModal.tsx b/frontend/src/components/shared/AudioSettingsModal.tsx index ab383b84..fd9b3b0f 100644 --- a/frontend/src/components/shared/AudioSettingsModal.tsx +++ b/frontend/src/components/shared/AudioSettingsModal.tsx @@ -34,6 +34,10 @@ try { export type AudioGenerationSettings = { voiceId: string; + customVoiceId?: string; + useVoiceClone?: boolean; + voiceSampleUrl?: string; + voiceCloneEngine?: string; speed: number; volume: number; pitch: number; diff --git a/frontend/src/components/shared/VoiceSelector.tsx b/frontend/src/components/shared/VoiceSelector.tsx index e193a25c..d8d03949 100644 --- a/frontend/src/components/shared/VoiceSelector.tsx +++ b/frontend/src/components/shared/VoiceSelector.tsx @@ -136,7 +136,7 @@ const PREDEFINED_VOICES: VoiceOption[] = [ { id: "Exuberant_Girl", name: "Exuberant Girl", personality: "Joyful, expressive female voice - ideal for celebrations", previewUrl: VOICE_PREVIEW_MAP.Exuberant_Girl, gender: "female", category: "creative" }, ]; -const VOICE_CLONE_ID = "MY_VOICE_CLONE"; +export const VOICE_CLONE_ID = "MY_VOICE_CLONE"; export const VoiceSelector: React.FC = ({ value, diff --git a/frontend/src/hooks/usePodcastProjectState.ts b/frontend/src/hooks/usePodcastProjectState.ts index 75f94c9d..f5b33965 100644 --- a/frontend/src/hooks/usePodcastProjectState.ts +++ b/frontend/src/hooks/usePodcastProjectState.ts @@ -60,6 +60,9 @@ export interface PodcastProjectState { // Backend project creation status — prevents 404 sync calls before project exists backendProjectCreated?: boolean; + + // Track last synced phase to prevent duplicate syncs + lastSyncedPhase?: string | null; } const DEFAULT_KNOBS: Knobs = { @@ -162,21 +165,28 @@ export const usePodcastProjectState = () => { } }, [state]); - // Sync to database after major steps (debounced) + // Sync to database ONLY on phase transitions (not on every state change) + // This ensures we sync at: Create → Analyze → Research → Script → Render useEffect(() => { if (!state.project || !state.project.id || !state.backendProjectCreated) return; + if (!state.currentStep) return; + + // Skip if already synced this phase (handles duplicate calls from handleCreate/etc) + if (state.currentStep === state.lastSyncedPhase) { + return; + } - // Capture project ID to avoid closure issues const projectId = state.project.id; - // Clear existing timeout + // Debounce - wait for state to settle before syncing if (syncTimeoutRef.current) { clearTimeout(syncTimeoutRef.current); } - // Debounce database sync (wait 2 seconds after last change) syncTimeoutRef.current = setTimeout(async () => { try { + console.log(`[Sync] Saving project at phase: ${state.currentStep}`); + const dbState = { analysis: state.analysis, queries: state.queries, @@ -195,39 +205,37 @@ export const usePodcastProjectState = () => { status: state.currentStep === 'render' && state.renderJobs.every(j => j.status === 'completed') ? 'completed' : 'in_progress', }; - await podcastApi.saveProject(projectId, dbState); + const saved = await podcastApi.saveProject(projectId, dbState); + + if (saved) { + setState((prev) => ({ ...prev, lastSyncedPhase: prev.currentStep })); + console.log(`[Sync] Project saved successfully at phase: ${state.currentStep}`); + } else { + console.warn(`[Sync] Failed to save project at phase: ${state.currentStep} - will retry on next phase change`); + } } catch (error) { - console.error('Error syncing project to database:', error); - // Don't throw - localStorage is still working + console.error('[Sync] Error saving project:', error); } - }, 2000); + }, 1500); 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.bible, - state.researchProvider, - state.showScriptEditor, - state.showRenderQueue, - state.currentStep, - ]); + // Only sync when phase changes - not on every state field change + }, [state.currentStep, state.backendProjectCreated]); // Setters const setProject = useCallback((project: PodcastProjectState['project']) => { - setState((prev) => ({ ...prev, project, currentStep: project ? 'analysis' : null, updatedAt: new Date().toISOString() })); + const newStep = project ? 'analysis' : null; + setState((prev) => ({ + ...prev, + project, + currentStep: newStep, + lastSyncedPhase: prev.currentStep, // Mark previous phase as synced + updatedAt: new Date().toISOString() + })); }, []); const setAnalysis = useCallback((analysis: PodcastProjectState['analysis']) => { @@ -235,6 +243,7 @@ export const usePodcastProjectState = () => { ...prev, analysis, currentStep: analysis ? 'research' : prev.currentStep, + lastSyncedPhase: prev.currentStep, // Mark previous phase as synced updatedAt: new Date().toISOString() })); }, []); @@ -255,6 +264,7 @@ export const usePodcastProjectState = () => { ...prev, research, currentStep: research ? 'script' : prev.currentStep, + lastSyncedPhase: prev.currentStep, // Mark previous phase as synced updatedAt: new Date().toISOString() })); }, []); @@ -272,6 +282,7 @@ export const usePodcastProjectState = () => { ...prev, scriptData, currentStep: scriptData ? 'render' : prev.currentStep, + lastSyncedPhase: prev.currentStep, // Mark previous phase as synced updatedAt: new Date().toISOString() })); }, []); diff --git a/frontend/src/services/billingService.ts b/frontend/src/services/billingService.ts index 6a41ce59..00d83458 100644 --- a/frontend/src/services/billingService.ts +++ b/frontend/src/services/billingService.ts @@ -93,9 +93,14 @@ billingAPI.interceptors.response.use( async (error) => { const originalRequest = error.config; - // Handle network errors + // Handle network errors - but NOT timeouts (backend might just be slow) if (!error.response) { - noteBackendUnavailable(error?.message || 'billing_network_error'); + const errorMsg = error?.message || ''; + const isTimeout = errorMsg.includes('timeout') || errorMsg.includes('ETIMEDOUT'); + + if (!isTimeout) { + noteBackendUnavailable(errorMsg || 'billing_network_error'); + } console.error('Billing API Network Error:', error.message); return Promise.reject(error); } diff --git a/frontend/src/services/podcastApi.ts b/frontend/src/services/podcastApi.ts index d4f548aa..31356aae 100644 --- a/frontend/src/services/podcastApi.ts +++ b/frontend/src/services/podcastApi.ts @@ -1,3 +1,4 @@ +import { noteBackendRecovered } from "../api/client"; import { ResearchProvider, ResearchConfig } from "./blogWriterApi"; import { storyWriterApi, @@ -28,12 +29,42 @@ const DEFAULT_KNOBS: Knobs = { voice_speed: 1, voice_id: "Wise_Woman", custom_voice_id: undefined, + is_voice_clone: undefined, + voice_sample_url: undefined, + voice_clone_engine: undefined, resolution: "720p", scene_length_target: 45, sample_rate: 24000, bitrate: "standard", }; +// In-memory cache for voice clone info to avoid re-fetching per scene +let _voiceCloneCache: { + customVoiceId?: string; + voiceSampleUrl?: string; + engine?: string; + isVoiceClone?: boolean; + timestamp: number; +} | null = null; +const VOICE_CLONE_CACHE_TTL = 30 * 60 * 1000; // 30 minutes + +export function getCachedVoiceCloneInfo() { + if (_voiceCloneCache && Date.now() - _voiceCloneCache.timestamp < VOICE_CLONE_CACHE_TTL) { + return _voiceCloneCache; + } + _voiceCloneCache = null; + return null; +} + +export function setCachedVoiceCloneInfo(info: { + customVoiceId?: string; + voiceSampleUrl?: string; + engine?: string; + isVoiceClone?: boolean; +}) { + _voiceCloneCache = { ...info, timestamp: Date.now() }; +} + // const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const createId = (prefix: string) => { @@ -244,9 +275,9 @@ const mapExaResearchResponse = (response: any): Research => { }; const ensurePreflight = async (operation: PreflightOperation) => { - console.log('[podcastApi] Running preflight for:', operation); + console.log('[podcastApi] Running preflight for:', operation.operation_type); const result = await checkPreflight(operation); - console.log('[podcastApi] Preflight result:', result); + console.log('[podcastApi] Preflight result: can_proceed=', result.can_proceed); if (!result.can_proceed) { const message = result.operations[0]?.message || "Pre-flight validation failed"; throw new Error(message); @@ -379,7 +410,9 @@ export const podcastApi = { bible: params.bible, analysis: params.analysis, }, { timeout: 300000 }); // 5 minute timeout for research - console.log('[podcastApi] Exa research response received:', response.status, response.data); + const sourceCount = response.data?.sources?.length || 0; + const insightCount = response.data?.key_insights?.length || 0; + console.log(`[podcastApi] Exa research response: status=${response.status}, sources=${sourceCount}, insights=${insightCount}`); } catch (error: any) { console.error('[podcastApi] Exa research error:', error?.response?.status, error?.response?.data, error?.message); throw error; @@ -497,6 +530,9 @@ export const podcastApi = { scene: Scene; voiceId?: string; customVoiceId?: string; + useVoiceClone?: boolean; + voiceSampleUrl?: string; + voiceCloneEngine?: string; emotion?: string; // Fallback if scene doesn't have emotion speed?: number; volume?: number; @@ -600,7 +636,7 @@ export const podcastApi = { channel: params.channel || null, format: params.format || null, language_boost: params.languageBoost || null, - }); + }, { timeout: 300000 }); // 5 minute timeout for voice clone / TTS return { audioUrl: response.data.audio_url, @@ -623,12 +659,14 @@ export const podcastApi = { }, // Project persistence endpoints - async saveProject(projectId: string, state: any): Promise { + async saveProject(projectId: string, state: any): Promise { try { await aiApiClient.put(`/api/podcast/projects/${projectId}`, state); + return true; } catch (error) { console.error("Failed to save project to database:", error); - // Don't throw - localStorage fallback is acceptable + noteBackendRecovered(); + return false; } }, @@ -952,6 +990,9 @@ export const podcastApi = { scenes: { id: string; title: string; lines: { text: string }[] }[]; voiceId: string; customVoiceId?: string; + useVoiceClone?: boolean; + voiceSampleUrl?: string; + voiceCloneEngine?: string; speed: number; emotion: string; englishNormalization?: boolean;