From 63bb937796e18b449c846dda2ce980ef4d7e49b3 Mon Sep 17 00:00:00 2001 From: ajaysi Date: Fri, 3 Apr 2026 06:59:59 +0530 Subject: [PATCH] feat: podcast demo mode with ALWRITY_ENABLED_FEATURES support - Add ALWRITY_ENABLED_FEATURES env var for feature gating - Podcast-only mode: skip LLM bootstrap, scheduler, persona services - Enhance video generation prompt with scene context, analysis, narration - Add voice cloning support via custom_voice_id in WaveSpeed - Add text-to-speech for research results (browser speechSynthesis) - Fix render queue to sync images from script phase - Add WaveSpeed LLM pricing (gpt-oss-120b) - Fix podcast bible generation error handling - Refactor RouterManager for feature-based router loading --- backend/alwrity_utils/router_manager.py | 4 + backend/api/podcast/constants.py | 8 +- backend/api/podcast/handlers/analysis.py | 123 ++- backend/api/podcast/handlers/audio.py | 6 + backend/api/podcast/handlers/images.py | 58 ++ backend/api/podcast/handlers/projects.py | 18 +- backend/api/podcast/handlers/research.py | 149 +++- backend/api/podcast/handlers/script.py | 107 ++- backend/api/podcast/handlers/video.py | 7 +- backend/api/podcast/models.py | 17 + backend/app.py | 96 +- .../llm_providers/main_audio_generation.py | 2 + .../llm_providers/main_text_generation.py | 17 +- backend/services/podcast_bible_service.py | 32 +- backend/services/startup_health.py | 6 + .../story_writer/audio_generation_service.py | 7 +- .../services/subscription/pricing_service.py | 27 +- backend/services/wavespeed/client.py | 3 + .../services/wavespeed/generators/speech.py | 25 + backend/services/wavespeed/infinitetalk.py | 96 +- backend/start_alwrity_backend.py | 18 +- frontend/src/App.tsx | 475 +--------- frontend/src/api/brandAssets.ts | 1 + .../components/PodcastMaker/AnalysisPanel.tsx | 835 +++++------------- .../AnalysisPanel/AnalysisTabNav.tsx | 92 ++ .../AnalysisPanel/tabs/AudienceTab.tsx | 211 +++++ .../AnalysisPanel/tabs/CTATab.tsx | 34 + .../AnalysisPanel/tabs/GuestTab.tsx | 36 + .../AnalysisPanel/tabs/HookTab.tsx | 34 + .../AnalysisPanel/tabs/InputsTab.tsx | 191 ++++ .../AnalysisPanel/tabs/OutlineTab.tsx | 39 + .../AnalysisPanel/tabs/ResearchTab.tsx | 43 + .../AnalysisPanel/tabs/TakeawaysTab.tsx | 36 + .../AnalysisPanel/tabs/TitlesTab.tsx | 75 ++ .../PodcastMaker/AnalysisPanel/tabs/index.ts | 9 + .../components/PodcastMaker/CreateModal.tsx | 17 +- .../PodcastMaker/CreateStep/CreateActions.tsx | 279 +++++- .../src/components/PodcastMaker/FactCard.tsx | 2 + .../PodcastMaker/PodcastBiblePanel.tsx | 281 +++--- .../PodcastMaker/PodcastDashboard.tsx | 84 +- .../PodcastDashboard/QuerySelection.tsx | 226 ++++- .../PodcastDashboard/ResearchSummary.tsx | 64 +- .../PodcastDashboard/usePodcastWorkflow.ts | 168 +++- .../PodcastMaker/PodcastDashboard/utils.ts | 2 + .../components/PodcastMaker/ProjectList.tsx | 27 +- .../components/PodcastMaker/RenderQueue.tsx | 1 + .../RenderQueue/VideoRegenerateModal.tsx | 48 +- .../RenderQueue/useRenderQueue.ts | 50 +- .../ScriptEditor/AudioRegenerateModal.tsx | 28 +- .../PodcastMaker/ScriptEditor/SceneEditor.tsx | 107 ++- .../ScriptEditor/ScriptEditor.tsx | 3 +- frontend/src/components/PodcastMaker/types.ts | 7 + .../PodcastMaker/ui/PrimaryButton.tsx | 14 +- .../components/shared/TextToSpeechButton.tsx | 169 ++++ .../src/components/shared/VoiceSelector.tsx | 321 +++++++ frontend/src/hooks/usePodcastProjectState.ts | 9 +- frontend/src/hooks/useTextToSpeech.ts | 190 ++++ frontend/src/services/podcastApi.ts | 131 ++- 58 files changed, 3568 insertions(+), 1597 deletions(-) create mode 100644 frontend/src/components/PodcastMaker/AnalysisPanel/AnalysisTabNav.tsx create mode 100644 frontend/src/components/PodcastMaker/AnalysisPanel/tabs/AudienceTab.tsx create mode 100644 frontend/src/components/PodcastMaker/AnalysisPanel/tabs/CTATab.tsx create mode 100644 frontend/src/components/PodcastMaker/AnalysisPanel/tabs/GuestTab.tsx create mode 100644 frontend/src/components/PodcastMaker/AnalysisPanel/tabs/HookTab.tsx create mode 100644 frontend/src/components/PodcastMaker/AnalysisPanel/tabs/InputsTab.tsx create mode 100644 frontend/src/components/PodcastMaker/AnalysisPanel/tabs/OutlineTab.tsx create mode 100644 frontend/src/components/PodcastMaker/AnalysisPanel/tabs/ResearchTab.tsx create mode 100644 frontend/src/components/PodcastMaker/AnalysisPanel/tabs/TakeawaysTab.tsx create mode 100644 frontend/src/components/PodcastMaker/AnalysisPanel/tabs/TitlesTab.tsx create mode 100644 frontend/src/components/PodcastMaker/AnalysisPanel/tabs/index.ts create mode 100644 frontend/src/components/shared/TextToSpeechButton.tsx create mode 100644 frontend/src/components/shared/VoiceSelector.tsx create mode 100644 frontend/src/hooks/useTextToSpeech.ts diff --git a/backend/alwrity_utils/router_manager.py b/backend/alwrity_utils/router_manager.py index 363bc486..3b7c2f0c 100644 --- a/backend/alwrity_utils/router_manager.py +++ b/backend/alwrity_utils/router_manager.py @@ -116,6 +116,10 @@ class RouterManager: if "all" in enabled_features: return True + # Skip core routers in podcast-only mode (they require non-podcast features) + if enabled_features == {"podcast"}: + return False + # If no required features specified, include by default if not required_features: return True diff --git a/backend/api/podcast/constants.py b/backend/api/podcast/constants.py index 493af908..edc4e235 100644 --- a/backend/api/podcast/constants.py +++ b/backend/api/podcast/constants.py @@ -6,6 +6,7 @@ Centralized constants and directory configuration for podcast module. from pathlib import Path from typing import Literal +from loguru import logger from services.story_writer.audio_generation_service import StoryAudioGenerationService # Directory paths @@ -45,11 +46,14 @@ def get_podcast_media_dir( }[media_type] if user_id: - tenant_media_dir = ROOT_DIR / "workspace" / f"workspace_{_sanitize_user_id(user_id)}" / "media" / media_subdir + sanitized = _sanitize_user_id(user_id) + tenant_media_dir = ROOT_DIR / "workspace" / f"workspace_{sanitized}" / "media" / media_subdir resolved_dir = tenant_media_dir.resolve() else: resolved_dir = (DATA_MEDIA_DIR / 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}") + if ensure_exists: resolved_dir.mkdir(parents=True, exist_ok=True) @@ -61,7 +65,9 @@ def get_podcast_media_read_dirs(media_type: MediaType, user_id: str | None = Non 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 diff --git a/backend/api/podcast/handlers/analysis.py b/backend/api/podcast/handlers/analysis.py index a5ac2fd3..a1e7dd42 100644 --- a/backend/api/podcast/handlers/analysis.py +++ b/backend/api/podcast/handlers/analysis.py @@ -5,10 +5,11 @@ Analysis endpoint for podcast ideas. """ from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from typing import Dict, Any, Optional, List import json import uuid from sqlalchemy.orm import Session +from pydantic import BaseModel from services.database import get_db from middleware.auth_middleware import get_current_user @@ -258,6 +259,10 @@ Return JSON with: - top_keywords: 5 podcast-relevant keywords/phrases - suggested_outlines: 2 items, each with title (<=60 chars) and 4-6 short segments (bullet-friendly, factual) - title_suggestions: 3 concise episode titles +- episode_hook: one compelling 15-30 second opening hook/angle that grabs attention +- key_takeaways: 3-5 actionable insights listeners will learn +- guest_talking_points: (if guest included) 3-4 suggested questions/angles for guest interview +- listener_cta: one clear call-to-action for listeners - research_queries: array of {{"query": "string", "rationale": "string"}} - exa_suggested_config: suggested Exa search options with: - exa_search_type: "auto" | "neural" | "keyword" @@ -271,7 +276,10 @@ Return JSON with: Requirements: - Keep language factual, actionable, and suited for spoken audio. - Avoid narrative fiction tone. -- Prefer 2024-2025 context. +- For research queries: Mix of time-sensitive and evergreen queries: + - 2-3 queries should focus on latest 2025-2026 developments, trends, and data (use year in query) + - 2-3 queries should be evergreen/fundamental (concepts, definitions, best practices, proven strategies) - do NOT include years in these +- Today's date is April 2026. """ try: @@ -305,6 +313,10 @@ Requirements: top_keywords = data.get("top_keywords") or [] suggested_outlines = data.get("suggested_outlines") or [] title_suggestions = data.get("title_suggestions") or [] + episode_hook = data.get("episode_hook") or "" + key_takeaways = data.get("key_takeaways") or [] + guest_talking_points = data.get("guest_talking_points") or [] + listener_cta = data.get("listener_cta") or "" research_queries = data.get("research_queries") or [] exa_suggested_config = data.get("exa_suggested_config") or None @@ -314,6 +326,10 @@ Requirements: top_keywords=top_keywords, suggested_outlines=suggested_outlines, title_suggestions=title_suggestions, + episode_hook=episode_hook, + key_takeaways=key_takeaways, + guest_talking_points=guest_talking_points, + listener_cta=listener_cta, research_queries=research_queries, exa_suggested_config=exa_suggested_config, bible=bible_obj.model_dump() if bible_obj else None, @@ -321,3 +337,106 @@ Requirements: avatar_prompt=final_avatar_prompt, ) + +class RegenerateQueriesRequest(BaseModel): + idea: str + feedback: str + existing_analysis: Optional[Dict[str, Any]] = None + bible: Optional[Dict[str, Any]] = None + + +class RegenerateQueriesResponse(BaseModel): + research_queries: List[Dict[str, str]] + + +@router.post("/regenerate-queries", response_model=RegenerateQueriesResponse) +async def regenerate_research_queries( + request: RegenerateQueriesRequest, + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Regenerate research queries based on user feedback and existing analysis. + """ + user_id = require_authenticated_user(current_user) + + # Build context from existing analysis + idea = request.idea + feedback = request.feedback + + # Get topic, keywords, audience from existing analysis if provided + topic = idea + keywords = "" + audience = "" + if request.existing_analysis: + topic = request.existing_analysis.get("title_suggestions", [idea])[0] if request.existing_analysis.get("title_suggestions") else idea + keywords = ", ".join(request.existing_analysis.get("top_keywords", [])[:5]) + audience = request.existing_analysis.get("audience", "") + + # Serialize Bible context if provided + bible_context = "" + if request.bible: + try: + bible_service = PodcastBibleService() + from models.podcast_bible_models import PodcastBible + bible_data = PodcastBible(**request.bible) + bible_context = bible_service.serialize_bible(bible_data) + except Exception as e: + logger.warning(f"Failed to serialize bible for query regeneration: {e}") + + prompt = f""" +You are a research strategist for podcast content. Given a podcast idea, existing analysis, and user feedback, +generate 7 new research queries that address the user's specific needs. + +{f"USER FEEDBACK: {feedback}" if feedback else ""} + +{f"EXISTING ANALYSIS CONTEXT:\n- Topic: {topic}\n- Keywords: {keywords}\n- Audience: {audience}\n" if request.existing_analysis else ""} +{f"PODCAST BIBLE CONTEXT:\n{bible_context}\n" if bible_context else ""} + +Podcast Idea: "{idea}" + +TASK: +Generate exactly 7 research queries that: +1. Incorporate the user's feedback direction +2. Build on the existing analysis context +3. Mix of time-sensitive (2025-2026) and evergreen topics +4. Are highly specific to the podcast topic + +Return JSON with: +- research_queries: array of {{"query": "string", "rationale": "string"}} + +Requirements: +- At least 2-3 queries should focus on latest 2025-2026 developments (include year in query) +- At least 2-3 queries should be evergreen (concepts, definitions, best practices - NO year) +- Queries should be specific and actionable, not generic +""" + + try: + from services.llm_providers.main_text_generation import llm_text_gen + + raw = llm_text_gen( + prompt=prompt, + user_id=user_id, + json_struct={"research_queries": [{"query": "string", "rationale": "string"}]}, + preferred_provider=None, + flow_type="premium_tool", + ) + + # Parse response + if isinstance(raw, dict): + queries = raw.get("research_queries", []) + else: + # Try to parse as JSON + try: + parsed = json.loads(raw) if isinstance(raw, str) else raw + queries = parsed.get("research_queries", []) if isinstance(parsed, dict) else [] + except: + queries = [] + + return RegenerateQueriesResponse(research_queries=queries[:7]) + + except HTTPException: + raise + except Exception as exc: + logger.error(f"[Regenerate Queries] Failed for user {user_id}: {exc}") + raise HTTPException(status_code=500, detail=f"Regenerate queries failed: {exc}") + diff --git a/backend/api/podcast/handlers/audio.py b/backend/api/podcast/handlers/audio.py index b2ace1b8..18fe565e 100644 --- a/backend/api/podcast/handlers/audio.py +++ b/backend/api/podcast/handlers/audio.py @@ -126,12 +126,14 @@ async def generate_podcast_audio( 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) @@ -149,6 +151,8 @@ async def generate_podcast_audio( 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: raise HTTPException(status_code=500, detail=f"Audio generation failed: {exc}") @@ -387,7 +391,9 @@ async def serve_podcast_audio( raise HTTPException(status_code=400, detail="Invalid filename") user_id = require_authenticated_user(current_user) + logger.warning(f"[Podcast] serve_podcast_audio called: user_id={user_id}, filename={filename}") audio_path = _resolve_podcast_media_file(filename, "audio", user_id) + logger.warning(f"[Podcast] Resolved audio path: {audio_path}") return FileResponse(audio_path, media_type="audio/mpeg") diff --git a/backend/api/podcast/handlers/images.py b/backend/api/podcast/handlers/images.py index 9d5ba28a..d9c50023 100644 --- a/backend/api/podcast/handlers/images.py +++ b/backend/api/podcast/handlers/images.py @@ -104,6 +104,16 @@ async def generate_podcast_scene_image( # Otherwise, generate from scratch with podcast-optimized prompt image_prompt = "" # Initialize prompt variable + # Emotion to lighting mapping for visual tone + emotion_lighting = { + "happy": "warm, bright lighting, cheerful atmosphere", + "excited": "dynamic, energetic lighting with highlights", + "serious": "professional, balanced lighting, authoritative feel", + "curious": "soft, inviting lighting, thoughtful atmosphere", + "confident": "strong, dramatic lighting, authoritative look", + "neutral": "professional, balanced lighting" + } + if base_avatar_bytes: # Use Ideogram Character API for consistent character generation # Use custom prompt if provided, otherwise build scene-specific prompt @@ -127,6 +137,28 @@ async def generate_podcast_scene_image( if bible_obj.host.look: prompt_parts.append(f"Host Look: {bible_obj.host.look}") + # Scene emotion for visual tone + emotion_lighting = { + "happy": "warm, bright lighting, cheerful atmosphere", + "excited": "dynamic, energetic lighting with highlights", + "serious": "professional, balanced lighting, authoritative feel", + "curious": "soft, inviting lighting, thoughtful atmosphere", + "confident": "strong, dramatic lighting, authoritative look", + "neutral": "professional, balanced lighting" + } + scene_emotion = request.scene_emotion + if scene_emotion and scene_emotion in emotion_lighting: + prompt_parts.append(emotion_lighting[scene_emotion]) + + # AI Analysis context for visual relevance + if request.analysis: + keywords = request.analysis.get("topKeywords", [])[:5] + if keywords: + prompt_parts.append(f"Keywords: {', '.join(keywords)}") + audience = request.analysis.get("audience", "") + if audience: + prompt_parts.append(f"Target: {audience}") + # Scene content insights for visual context if request.scene_content: content_preview = request.scene_content[:200].replace("\n", " ").strip() @@ -139,6 +171,12 @@ async def generate_podcast_scene_image( visual_keywords.append("modern tech studio setting") if any(word in content_lower for word in ["business", "growth", "strategy", "market"]): visual_keywords.append("professional business studio") + if any(word in content_lower for word in ["nature", "outdoor", "environment", "green"]): + visual_keywords.append("natural outdoor setting") + if any(word in content_lower for word in ["medical", "health", "wellness"]): + visual_keywords.append("clean medical studio") + if any(word in content_lower for word in ["education", "learning", "students"]): + visual_keywords.append("classroom or educational setting") if visual_keywords: prompt_parts.append(", ".join(visual_keywords)) @@ -265,6 +303,19 @@ async def generate_podcast_scene_image( if request.scene_title: prompt_parts.append(f"Scene theme: {request.scene_title}") + # Scene emotion for visual tone (no avatar branch) + if request.scene_emotion and request.scene_emotion in emotion_lighting: + prompt_parts.append(emotion_lighting[request.scene_emotion]) + + # AI Analysis context (no avatar branch) + if request.analysis: + keywords = request.analysis.get("topKeywords", [])[:5] + if keywords: + prompt_parts.append(f"Keywords: {', '.join(keywords)}") + audience = request.analysis.get("audience", "") + if audience: + prompt_parts.append(f"Target: {audience}") + # Content context for visual relevance if request.scene_content: content_preview = request.scene_content[:150].replace("\n", " ").strip() @@ -276,6 +327,12 @@ async def generate_podcast_scene_image( visual_keywords.append("modern technology aesthetic") if any(word in content_lower for word in ["business", "growth", "strategy", "market"]): visual_keywords.append("professional business environment") + if any(word in content_lower for word in ["nature", "outdoor", "environment"]): + visual_keywords.append("natural outdoor setting") + if any(word in content_lower for word in ["medical", "health", "wellness"]): + visual_keywords.append("clean medical studio") + if any(word in content_lower for word in ["education", "learning", "students"]): + visual_keywords.append("classroom or educational setting") if visual_keywords: prompt_parts.append(", ".join(visual_keywords)) @@ -379,6 +436,7 @@ async def generate_podcast_scene_image( provider=result.provider, model=result.model, cost=cost, + image_prompt=image_prompt, ) except HTTPException: diff --git a/backend/api/podcast/handlers/projects.py b/backend/api/podcast/handlers/projects.py index d4d8430b..ebb6e176 100644 --- a/backend/api/podcast/handlers/projects.py +++ b/backend/api/podcast/handlers/projects.py @@ -27,7 +27,10 @@ async def create_project( db: Session = Depends(get_db), current_user: Dict[str, Any] = Depends(get_current_user), ): - """Create a new podcast project.""" + """Create a new podcast project. + + If a project with the same idea already exists, return 409 conflict with existing project info. + """ try: user_id = current_user.get("user_id") or current_user.get("id") if not user_id: @@ -40,6 +43,19 @@ async def create_project( if existing: raise HTTPException(status_code=400, detail="Project ID already exists") + # Check for duplicate idea (case-insensitive partial match) + existing_idea = service.get_project_by_idea(user_id, request.idea) + if existing_idea: + raise HTTPException( + status_code=409, + detail={ + "message": "A project with similar idea already exists", + "existing_project_id": existing_idea.project_id, + "existing_idea": existing_idea.idea, + "existing_status": existing_idea.status, + } + ) + project = service.create_project( user_id=user_id, project_id=request.project_id, diff --git a/backend/api/podcast/handlers/research.py b/backend/api/podcast/handlers/research.py index f034732d..e1b84cfd 100644 --- a/backend/api/podcast/handlers/research.py +++ b/backend/api/podcast/handlers/research.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException from typing import Dict, Any, List from types import SimpleNamespace import json +import re from middleware.auth_middleware import get_current_user from api.story_writer.utils.auth import require_authenticated_user @@ -36,10 +37,16 @@ async def podcast_research_exa( Uses Podcast Bible and Analysis context for hyper-personalization. """ 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}") + queries = [q.strip() for q in request.queries if q and q.strip()] if not queries: raise HTTPException(status_code=400, detail="At least one query is required for research.") + + logger.warning(f"[Podcast Research] EXACT queries being sent to Exa: {queries}") exa_cfg = request.exa_config or PodcastExaConfig() cfg = SimpleNamespace( @@ -52,6 +59,7 @@ async def podcast_research_exa( ) provider = ExaResearchProvider() + logger.warning(f"[Podcast Research] Provider initialized, starting Exa search...") # --- Context Building --- bible_service = PodcastBibleService() @@ -68,9 +76,16 @@ async def podcast_research_exa( if request.analysis: analysis_context = f""" PODCAST ANALYSIS CONTEXT: -Audience: {request.analysis.get('audience', 'General')} +======================== +Topic: {request.topic} +Target Audience: {request.analysis.get('audience', 'General')} Content Type: {request.analysis.get('content_type', 'Informative')} Top Keywords: {', '.join(request.analysis.get('top_keywords', []))} + +Episode Hook (Intro): {request.analysis.get('episode_hook', 'N/A')} +Key Takeaways: {', '.join(request.analysis.get('key_takeaways', [])) or 'N/A'} +Guest Talking Points: {', '.join(request.analysis.get('guest_talking_points', [])) or 'N/A'} +Listener CTA: {request.analysis.get('listener_cta', 'N/A')} """ # Exa search params @@ -84,6 +99,7 @@ Top Keywords: {', '.join(request.analysis.get('top_keywords', []))} try: # 1. RUN EXA SEARCH + logger.warning(f"[Podcast Research] Calling Exa search with topic: {request.topic[:100]}...") result = await provider.search( prompt=request.topic, topic=request.topic, @@ -92,8 +108,9 @@ Top Keywords: {', '.join(request.analysis.get('top_keywords', []))} config=cfg, user_id=user_id, ) + logger.warning(f"[Podcast Research] Exa search completed, got {len(result.get('sources', []))} sources") except Exception as exc: - logger.error(f"[Podcast Exa Research] Search failed for user {user_id}: {exc}") + logger.error(f"[Podcast Exa Research] Search failed for user {user_id}: {exc}", exc_info=True) raise HTTPException(status_code=500, detail=f"Exa research failed: {exc}") # 2. EXTRACT INSIGHTS VIA LLM @@ -104,46 +121,77 @@ Top Keywords: {', '.join(request.analysis.get('top_keywords', []))} key_insights = [] if raw_content and sources: - logger.info(f"[Podcast Research] Extracting insights from {len(sources)} sources for user {user_id}") + logger.warning(f"[Podcast Research] Extracting insights from {len(sources)} sources for user {user_id}") + + # Build list of research queries used for this search + queries_used = ", ".join([f"Query {i+1}: {q}" for i, q in enumerate(queries)]) if queries else "No specific queries" prompt = f""" -You are an expert research analyst for a high-end podcast production team. -Your task is to analyze the following research data and extract deep, actionable insights for a podcast episode. +You are an expert research analyst and content strategist for a high-end podcast production team. +Your task is to analyze the research data and extract deep, podcast-ready insights. PODCAST CONTEXT: -Topic: {request.topic} +================ +Main Topic: {request.topic} + +RESEARCH QUERIES USED: +===================== +{queries_used} + +PODCAST BIBLE & BRAND CONTEXT: +============================== {bible_context} + +PODCAST ANALYSIS (from AI Analysis phase): +========================================== {analysis_context} RESEARCH DATA (from {len(sources)} sources): +============================================ {raw_content} -TASK: -1. Provide a comprehensive summary (2-3 paragraphs) of the most important findings. Use Markdown for formatting (bolding, lists). -2. Extract 3-5 "Key Insights". Each insight should have a title and a detailed explanation. -3. For each insight, identify which source indices (e.g. 1, 2) it was derived from. +YOUR TASK: +========== +As a podcast research expert, analyze this data and create content that will: +1. Engage the specific target audience identified above +2. Support the episode hook and key takeaways already planned +3. Provide talking points that complement the guest's expertise +4. Include a compelling call-to-action for listeners -NOTE: The research data includes "Key Highlights", "Summaries", and "Excerpts" from various sources. -Pay special attention to the "Key Highlights" sections as they contain the most relevant information extracted by the neural search engine. - -Return JSON structure: +REQUIRED OUTPUT (JSON): +======================= {{ - "summary": "Detailed markdown summary...", + "summary": "2-3 paragraph comprehensive summary in Markdown. Start with a hook that matches the episode intro. Include specific data points, expert quotes, and trends.", "key_insights": [ {{ - "title": "Insight Title", - "content": "Detailed markdown content...", - "source_indices": [1, 2] + "title": "Catchy, engaging title for this insight", + "content": "3-4 sentences with specific facts, quotes, or data. Write in a conversational tone suitable for a podcast host to discuss.", + "source_indices": [1, 2, 3], + "podcast_talking_points": ["Point 1 host can expand on", "Counter-point or follow-up", "Question to ask guest"] }} - ] + ], + "expert_quotes": [ + {{ + "quote": "Direct quote from source", + "source_index": 1, + "context": "Why this quote matters for the podcast" + }} + ], + "listener_cta_suggestions": ["Specific action listener can take", "Resource to share", "Next episode preview"] }} -Requirements: -- Ensure insights are deep, not just superficial facts. Look for trends, expert opinions, and specific data points. -- Tone should be professional, insightful, and ready for a podcast host to discuss. -- Avoid generic filler. +QUALITY STANDARDS: +================== +- INSIGHTS MUST BE DEEP, not superficial - avoid generic statements +- Include SPECIFIC DATA POINTS, percentages, statistics when available +- Extract EXPERT QUOTES that hosts can reference +- Identify GAPS in the research where more depth is needed +- Make content naturally flow into the planned episode hook and CTA +- Write in a CONVERSATIONAL tone - how a host would actually speak +- Flag any CONTROVERSIAL or debatable claims for host to address """ try: + logger.warning(f"[Podcast Research] Calling LLM for insight extraction...") llm_response = llm_text_gen( prompt=prompt, user_id=user_id, @@ -151,15 +199,45 @@ Requirements: preferred_provider=None, flow_type="premium_tool", ) + logger.warning(f"[Podcast Research] LLM response received, length: {len(llm_response) if llm_response else 0}") - # Normalize response + # Normalize response - handle both string and dict responses + data = None if isinstance(llm_response, str): - data = json.loads(llm_response) + try: + # Try to fix common JSON issues + fixed_response = llm_response.strip() + # Remove markdown code blocks if present + if fixed_response.startswith("```"): + fixed_response = fixed_response.split("```")[1] + if fixed_response.startswith("json"): + fixed_response = fixed_response[4:] + fixed_response = fixed_response.strip() + data = json.loads(fixed_response) + except json.JSONDecodeError as json_err: + logger.warning(f"[Podcast Research] Failed to parse JSON: {json_err}. Response preview: {llm_response[:500]}...") + # Try to extract JSON from response using regex + json_match = re.search(r'\{.*\}', llm_response, re.DOTALL) + if json_match: + try: + data = json.loads(json_match.group()) + logger.warning("[Podcast Research] Successfully extracted JSON via regex") + except: + pass else: data = llm_response - - summary = data.get("summary", "") - key_insights = [PodcastResearchInsight(**insight) for insight in data.get("key_insights", [])] + + if data: + try: + summary = data.get("summary", "") + key_insights = [PodcastResearchInsight(**insight) for insight in data.get("key_insights", [])] + except Exception as insight_err: + logger.warning(f"[Podcast Research] Failed to parse insights: {insight_err}. Data keys: {list(data.keys()) if isinstance(data, dict) else 'not a dict'}") + summary = data.get("summary", "") if isinstance(data, dict) else "" + key_insights = [] + else: + summary = "" + key_insights = [] except HTTPException: raise except Exception as exc: @@ -183,21 +261,32 @@ Requirements: logger.warning(f"[Podcast Exa Research] Failed to track usage: {track_err}") sources_payload = [] + seen_urls = set() for src in sources: + url = src.get("url", "") + # Skip duplicates + if url and url in seen_urls: + continue + if url: + seen_urls.add(url) + try: sources_payload.append(PodcastExaSource(**src)) except Exception: sources_payload.append(PodcastExaSource(**{ "title": src.get("title", ""), - "url": src.get("url", ""), - "excerpt": src.get("excerpt", ""), + "url": url, + "excerpt": src.get("excerpt") or (src.get("highlights")[0] if src.get("highlights") else "") or src.get("summary", ""), "published_at": src.get("published_at"), + "publishedDate": src.get("publishedDate"), "highlights": src.get("highlights"), "summary": src.get("summary"), "source_type": src.get("source_type"), "index": src.get("index"), "image": src.get("image"), "author": src.get("author"), + "text": src.get("text"), + "credibility_score": src.get("credibility_score"), })) return PodcastExaResearchResponse( diff --git a/backend/api/podcast/handlers/script.py b/backend/api/podcast/handlers/script.py index 67ad6d9a..ce24eded 100644 --- a/backend/api/podcast/handlers/script.py +++ b/backend/api/podcast/handlers/script.py @@ -1,11 +1,12 @@ """ Podcast Script Handlers -Script generation endpoint. +Script generation and approval endpoints. """ from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from typing import Dict, Any, Optional +from pydantic import BaseModel, Field import json from middleware.auth_middleware import get_current_user @@ -24,6 +25,29 @@ from ..models import ( router = APIRouter() +class SceneApprovalRequest(BaseModel): + project_id: str = Field(..., min_length=1) + scene_id: str = Field(..., min_length=1) + approved: bool = True + notes: Optional[str] = None + + +@router.post("/script/approve") +async def approve_podcast_scene( + request: SceneApprovalRequest, + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + """Persist scene approval metadata for auditing (podcast-specific).""" + user_id = require_authenticated_user(current_user) + logger.warning(f"[Podcast] Scene approval recorded user={user_id} project={request.project_id} scene={request.scene_id} approved={request.approved}") + return { + "success": True, + "project_id": request.project_id, + "scene_id": request.scene_id, + "approved": request.approved, + } + + @router.post("/script", response_model=PodcastScriptResponse) async def generate_podcast_script( request: PodcastScriptRequest, @@ -33,6 +57,10 @@ 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}") + logger.warning(f"[ScriptGen] Has research: {bool(request.research)}, Has bible: {bool(request.bible)}, Has analysis: {bool(request.analysis)}") # Build comprehensive research context for higher-quality scripts research_context = "" @@ -77,55 +105,53 @@ async def generate_podcast_script( # Extract Analysis and Outline context for grounding analysis_context = "" if request.analysis: - analysis_context = f""" -TARGET AUDIENCE: {request.analysis.get('audience', 'General')} -CONTENT TYPE: {request.analysis.get('contentType', 'Conversational')} -TOP KEYWORDS: {', '.join(request.analysis.get('topKeywords', []))} -""" + try: + audience = request.analysis.get('audience', '') or '' + content_type = request.analysis.get('contentType', '') or '' + keywords = request.analysis.get('topKeywords', []) or [] + analysis_context = f"ANALYSIS: Audience={audience} | Type={content_type} | Keywords={', '.join(keywords[:8])}" + except: + pass outline_context = "" if request.outline: - outline_context = f""" -REFINED EPISODE OUTLINE (Follow this structure closely): -Title: {request.outline.get('title', 'N/A')} -Segments: {' | '.join(request.outline.get('segments', []))} -""" + try: + title = request.outline.get('title', '') or '' + segments = request.outline.get('segments', []) or [] + outline_context = f"OUTLINE: {title} - {' | '.join(segments[:5])}" + except: + pass - prompt = f"""You are an expert podcast script planner. Create natural, conversational podcast scenes. + prompt = f"""Create a podcast script with scenes and dialogue. -{f"PODCAST BIBLE (Hyper-Personalization Context):\n{bible_context}\n" if bible_context else ""} -{f"ANALYSIS CONTEXT:\n{analysis_context}\n" if analysis_context else ""} -{f"REFINED OUTLINE:\n{outline_context}\n" if outline_context else ""} +{f"BIBLE: {bible_context[:1500]}" if bible_context else ""} +{f"{analysis_context}" if analysis_context else ""} +{f"{outline_context}" if outline_context else ""} +{f"RESEARCH: {research_context[:1200]}" if research_context else ""} -Podcast Idea: "{request.idea}" -Duration: ~{request.duration_minutes} minutes -Speakers: {request.speakers} (Host + optional Guest) +Topic: "{request.idea}" +Duration: {request.duration_minutes} min | Speakers: {request.speakers} -{f"RESEARCH CONTEXT:\n{research_context}\n" if research_context else ""} +Return JSON with scenes array. Each scene: +- id: string +- title: short title (<=50 chars) +- duration: seconds (total/5) +- emotion: neutral|happy|excited|serious|curious|confident +- lines: array of {{speaker, text, emphasis}} + - Use 2-4 LINES PER SCENE (shorter script = lower TTS costs) + - Each line: 1-3 sentences, conversational + - Plain text only, no markdown -Return JSON with: -- scenes: array of scenes. Each scene has: - - id: string - - title: short scene title (<= 60 chars) - - duration: duration in seconds (evenly split across total duration) - - emotion: string (one of: "neutral", "happy", "excited", "serious", "curious", "confident") - - lines: array of {{"speaker": "...", "text": "...", "emphasis": boolean}} - * Write natural, conversational dialogue - * Each line can be a sentence or a few sentences that flow together - * Use plain text only - no markdown formatting (no asterisks, underscores, etc.) - * Mark "emphasis": true for key statistics or important points - -Guidelines: -- Write for spoken delivery: conversational, natural, with contractions. -- Follow the interaction tone specified in the Bible. -- Ensure the Host persona matches the background and personality traits from the Bible. -- Structure the intro and outro scenes according to the Bible's "Intro Format" and "Outro Format". -- Adhere to any constraints mentioned in the Bible. -- Use insights from the Research Context to ground the conversation in facts. -- IMPORTANT: Follow the REFINED OUTLINE segments as the primary structure for the episode. +COST OPTIMIZATION: +- 5-6 scenes max for {request.duration_minutes} min episode +- Concise, information-dense dialogue +- Skip filler words and redundant phrases +- Focus on unique insights from research +- Make every line count toward value delivery """ try: + logger.warning(f"[ScriptGen] Calling LLM to generate script (prompt length: {len(prompt)})...") raw = llm_text_gen( prompt=prompt, user_id=user_id, @@ -133,6 +159,7 @@ Guidelines: preferred_provider=None, flow_type="premium_tool", ) + logger.warning(f"[ScriptGen] LLM response received, length: {len(raw) if raw else 0}") except HTTPException: raise except Exception as exc: diff --git a/backend/api/podcast/handlers/video.py b/backend/api/podcast/handlers/video.py index 1f3ecef7..5e2f26bf 100644 --- a/backend/api/podcast/handlers/video.py +++ b/backend/api/podcast/handlers/video.py @@ -140,17 +140,20 @@ def _execute_podcast_video_task( except Exception as e: logger.warning(f"[Podcast] Failed to fetch project context for video generation: {e}") - # Prepare scene data for animation + # Prepare scene data for animation - include all context for enhanced prompt scene_data = { "scene_number": scene_number, "title": request.scene_title, "scene_id": request.scene_id, + "image_prompt": request.scene_image_prompt, + "description": request.scene_narration, + "lines": [{"text": request.scene_narration}] if request.scene_narration else [], } story_context = { "project_id": request.project_id, "type": "podcast", "bible": project_bible, - "analysis": project_analysis, + "analysis": request.analysis or project_analysis, # Use passed analysis or fallback to DB } animation_result = animate_scene_with_voiceover( diff --git a/backend/api/podcast/models.py b/backend/api/podcast/models.py index 74d0f2be..14673f94 100644 --- a/backend/api/podcast/models.py +++ b/backend/api/podcast/models.py @@ -63,6 +63,10 @@ class PodcastAnalyzeResponse(BaseModel): top_keywords: list[str] suggested_outlines: list[Dict[str, Any]] title_suggestions: list[str] + episode_hook: Optional[str] = None + key_takeaways: Optional[list[str]] = None + guest_talking_points: Optional[list[str]] = None + listener_cta: Optional[str] = None research_queries: Optional[List[Dict[str, str]]] = None exa_suggested_config: Optional[Dict[str, Any]] = None bible: Optional[Dict[str, Any]] = None @@ -142,12 +146,15 @@ class PodcastExaSource(BaseModel): url: str = "" excerpt: str = "" published_at: Optional[str] = None + publishedDate: Optional[str] = None # Exa format highlights: Optional[List[str]] = None summary: Optional[str] = None source_type: Optional[str] = None index: Optional[int] = None image: Optional[str] = None author: Optional[str] = None + text: Optional[str] = None # Exa full text + credibility_score: Optional[float] = None # Exa scores class PodcastResearchInsight(BaseModel): @@ -155,6 +162,9 @@ class PodcastResearchInsight(BaseModel): title: str content: str source_indices: List[int] = [] + podcast_talking_points: Optional[List[str]] = [] # Talking points for host to expand on + expert_quotes: Optional[List[Dict[str, str]]] = [] # Quotes from sources + listener_cta_suggestions: Optional[List[str]] = [] # CTA suggestions class PodcastExaResearchResponse(BaseModel): @@ -178,6 +188,7 @@ class PodcastAudioRequest(BaseModel): scene_title: str text: str voice_id: Optional[str] = "Wise_Woman" + custom_voice_id: Optional[str] = None # Voice clone ID for custom voice speed: Optional[float] = 1.0 volume: Optional[float] = 1.0 pitch: Optional[float] = 0.0 @@ -263,7 +274,9 @@ class PodcastImageRequest(BaseModel): scene_id: str scene_title: str scene_content: Optional[str] = None # Optional: scene lines text for context + scene_emotion: Optional[str] = None # Optional: scene emotion for visual tone idea: Optional[str] = None # Optional: podcast idea for context + analysis: Optional[Dict[str, Any]] = Field(None, description="AI analysis for visual context (keywords, audience)") base_avatar_url: Optional[str] = None # Base avatar image URL for scene variations bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization") width: int = 1024 @@ -285,6 +298,7 @@ class PodcastImageResponse(BaseModel): provider: str model: Optional[str] = None cost: float + image_prompt: Optional[str] = None # Return the prompt used for generation class PodcastVideoGenerationRequest(BaseModel): @@ -295,6 +309,9 @@ class PodcastVideoGenerationRequest(BaseModel): audio_url: str = Field(..., description="URL to the generated audio file") avatar_image_url: Optional[str] = Field(None, description="URL to scene image (required for video generation)") bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization") + analysis: Optional[Dict[str, Any]] = Field(None, description="Podcast Analysis for context (content type, audience, takeaways, guest)") + scene_image_prompt: Optional[str] = Field(None, description="Original image generation prompt for visual context") + scene_narration: Optional[str] = Field(None, description="Scene narration/script lines for context") resolution: str = Field("720p", description="Video resolution (480p or 720p)") prompt: Optional[str] = Field(None, description="Optional animation prompt override") seed: Optional[int] = Field(-1, description="Random seed; -1 for random") diff --git a/backend/app.py b/backend/app.py index a76b06e0..6063c29c 100644 --- a/backend/app.py +++ b/backend/app.py @@ -9,59 +9,26 @@ builtins.Dict = typing.Dict builtins.Any = typing.Any builtins.Union = typing.Union -# Import onboarding models VERY early to ensure they're available before any services -from models.onboarding import APIKey, WebsiteAnalysis, ResearchPreferences, PersonaData, CompetitorAnalysis - - -from fastapi import FastAPI, HTTPException, Depends, Request, BackgroundTasks -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse -from pydantic import BaseModel -from typing import Dict, Any, Optional -import os -from loguru import logger -from dotenv import load_dotenv -import asyncio -from datetime import datetime - -# Import OnboardingSession right after basic imports to ensure it's available -from models.onboarding import OnboardingSession - -from services.subscription import monitoring_middleware - -# Import remaining onboarding models -from models import APIKey, WebsiteAnalysis, ResearchPreferences, PersonaData, CompetitorAnalysis - -# Import modular utilities -from alwrity_utils import HealthChecker, RateLimiter, FrontendServing, RouterManager -from alwrity_utils import OnboardingManager - -# Load environment variables -# Try multiple locations for .env file +# Load environment variables FIRST before any other imports from pathlib import Path +from dotenv import load_dotenv backend_dir = Path(__file__).parent project_root = backend_dir.parent +load_dotenv(backend_dir / '.env') +load_dotenv(project_root / '.env') +load_dotenv() -# Load from backend/.env first (higher priority), then root .env -load_dotenv(backend_dir / '.env') # backend/.env -load_dotenv(project_root / '.env') # root .env (fallback) -load_dotenv() # CWD .env (fallback) +# Set LOG_LEVEL early to WARNING to suppress DEBUG persona logs in podcast mode +import os +if os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() == "podcast": + os.environ["LOG_LEVEL"] = "WARNING" def get_enabled_features() -> set: - """Get enabled features from ALWRITY_ENABLED_FEATURES env var. - - Values: - - "all" - enable all features (default) - - comma-separated: "podcast,core" - - single feature: "podcast" - """ + """Get enabled features from ALWRITY_ENABLED_FEATURES env var.""" env_value = os.getenv("ALWRITY_ENABLED_FEATURES", "all").strip().lower() - if not env_value or env_value == "all": return {"all"} - return {f.strip() for f in env_value.split(",") if f.strip()} @@ -71,6 +38,32 @@ def is_podcast_only_demo_mode() -> bool: return "podcast" in enabled and "all" not in enabled +# Import onboarding models (after env is loaded) +from models.onboarding import APIKey, WebsiteAnalysis, ResearchPreferences, PersonaData, CompetitorAnalysis + + +# Import FastAPI and related +from fastapi import FastAPI, HTTPException, Depends, Request, BackgroundTasks +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from pydantic import BaseModel +from typing import Dict, Any, Optional +import os +import asyncio +from datetime import datetime +from loguru import logger + + +# Import modular utilities (skip OnboardingManager import in podcast-only mode) +from alwrity_utils import HealthChecker, RateLimiter, FrontendServing, RouterManager +if not is_podcast_only_demo_mode(): + from alwrity_utils import OnboardingManager + +# Import monitoring middleware +from services.subscription import monitoring_middleware + + def should_include_non_podcast_features() -> bool: """Check if non-podcast features should be included.""" enabled = get_enabled_features() @@ -94,8 +87,10 @@ from api.component_logic import router as component_logic_router # Import subscription API endpoints from api.subscription import router as subscription_router -# Import Step 3 onboarding routes -from api.onboarding_utils.step3_routes import router as step3_routes +# Import Step 3 onboarding routes (skip in podcast-only mode) +step3_routes = None +if not PODCAST_ONLY_DEMO_MODE: + from api.onboarding_utils.step3_routes import router as step3_routes # Import SEO tools router from routers.seo_tools import router as seo_tools_router @@ -218,7 +213,9 @@ router_manager = RouterManager(app) router_group_status: Dict[str, Dict[str, Any]] = {} onboarding_manager = None +# Only create OnboardingManager if NOT in podcast-only mode if not PODCAST_ONLY_DEMO_MODE: + from alwrity_utils import OnboardingManager onboarding_manager = OnboardingManager(app) # Middleware Order (FastAPI executes in REVERSE order of registration - LIFO): @@ -575,9 +572,12 @@ async def startup_event(): if startup_report.get("status") != "healthy": logger.error(f"Startup readiness finished with failures: {startup_report.get('errors', [])}") - # Start task scheduler - from services.scheduler import get_scheduler - await get_scheduler().start() + # Start task scheduler only if NOT in podcast-only mode + if not is_podcast_only_demo_mode(): + from services.scheduler import get_scheduler + await get_scheduler().start() + else: + logger.info("[Podcast] Skipping scheduler startup (podcast-only mode)") # Check Wix API key configuration wix_api_key = os.getenv('WIX_API_KEY') diff --git a/backend/services/llm_providers/main_audio_generation.py b/backend/services/llm_providers/main_audio_generation.py index a54454aa..bcc0d6b9 100644 --- a/backend/services/llm_providers/main_audio_generation.py +++ b/backend/services/llm_providers/main_audio_generation.py @@ -62,6 +62,7 @@ class VoiceCloneResult: def generate_audio( text: str, voice_id: str = "Wise_Woman", + custom_voice_id: Optional[str] = None, speed: float = 1.0, volume: float = 1.0, pitch: float = 0.0, @@ -173,6 +174,7 @@ def generate_audio( audio_bytes = client.generate_speech( text=text, voice_id=voice_id, + custom_voice_id=custom_voice_id, speed=speed, volume=volume, pitch=pitch, diff --git a/backend/services/llm_providers/main_text_generation.py b/backend/services/llm_providers/main_text_generation.py index 6e82c49c..f69c8f61 100644 --- a/backend/services/llm_providers/main_text_generation.py +++ b/backend/services/llm_providers/main_text_generation.py @@ -67,7 +67,7 @@ def llm_text_gen( resolved_flow_type = flow_type or ("sif_agent" if preferred_hf_models else "premium_tool") flow_tag = f"flow_type={resolved_flow_type}" - logger.info(f"[llm_text_gen][{flow_tag}] Starting text generation") + logger.warning(f"[llm_text_gen][{flow_tag}] Starting text generation") logger.debug(f"[llm_text_gen] Prompt length: {len(prompt)} characters") # Set default values for LLM parameters @@ -94,7 +94,7 @@ def llm_text_gen( primary_provider = provider_list[0] if primary_provider in ['wavespeed', 'wave']: gpt_provider = "wavespeed" - model = os.getenv('WAVESPEED_TEXT_MODEL', 'openai/gpt-oss-120b:cerebras') + model = os.getenv('WAVESPEED_TEXT_MODEL', 'openai/gpt-oss-120b') elif primary_provider in ['gemini', 'google']: gpt_provider = "google" model = "gemini-2.0-flash-001" @@ -111,7 +111,7 @@ def llm_text_gen( elif preferred_provider: if preferred_provider in ['wavespeed', 'wave']: gpt_provider = "wavespeed" - model = os.getenv('WAVESPEED_TEXT_MODEL', 'openai/gpt-oss-120b:cerebras') + model = os.getenv('WAVESPEED_TEXT_MODEL', 'openai/gpt-oss-120b') elif preferred_provider in ['openai', 'gpt']: gpt_provider = "openai" model = os.getenv('OPENAI_MODEL', 'gpt-4o-mini') @@ -166,7 +166,7 @@ def llm_text_gen( if api_key_manager.get_api_key("wavespeed"): available_providers.append("wavespeed") - logger.info( + logger.warning( f"[llm_text_gen][{flow_tag}] Provider preflight: env_provider='{env_provider or 'auto'}', " f"provider_list={provider_list}, strict_provider_mode={strict_provider_mode}, " f"available_providers={available_providers}, preferred_provider={preferred_provider or 'none'}, " @@ -278,7 +278,12 @@ def llm_text_gen( UsageSummary.billing_period == current_period ).first() - # No separate log here - we'll create unified log after API call and usage tracking + # Log subscription details before making the API call + if usage: + total_llm_calls = (usage.gemini_calls or 0) + (usage.openai_calls or 0) + (usage.anthropic_calls or 0) + (usage.mistral_calls or 0) + (usage.wavespeed_calls or 0) + logger.info(f"[llm_text_gen] Subscription check passed for user {user_id}: provider={actual_provider_name or gpt_provider}, tokens_requested={estimated_total_tokens}, current_usage=${usage.total_cost or 0:.4f}, calls_used={total_llm_calls}") + else: + logger.info(f"[llm_text_gen] Subscription check passed for user {user_id}: provider={actual_provider_name or gpt_provider}, tokens_requested={estimated_total_tokens}, new_user_no_usage_record") finally: db.close() @@ -363,7 +368,7 @@ def llm_text_gen( from services.llm_providers.wavespeed_provider import wavespeed_text_response response_text = wavespeed_text_response( prompt=prompt, - model=model or "openai/gpt-oss-120b:cerebras", + model=model or "openai/gpt-oss-120b", temperature=temperature, max_tokens=max_tokens, top_p=top_p, diff --git a/backend/services/podcast_bible_service.py b/backend/services/podcast_bible_service.py index 1c35a98e..4ea083b1 100644 --- a/backend/services/podcast_bible_service.py +++ b/backend/services/podcast_bible_service.py @@ -15,14 +15,31 @@ class PodcastBibleService: """Service for generating and managing the Podcast Bible.""" def __init__(self): - self.personalization_service = PersonalizationService() + try: + from services.product_marketing.personalization_service import PersonalizationService + self.personalization_service = PersonalizationService() + except Exception as e: + logger.warning(f"Failed to initialize PersonalizationService: {e}") + self.personalization_service = None def generate_bible(self, user_id: str, project_id: str) -> PodcastBible: """Generate a Podcast Bible from onboarding data.""" logger.info(f"Generating Podcast Bible for user {user_id}") try: - preferences = self.personalization_service.get_user_preferences(user_id) or {} + if not self.personalization_service: + logger.warning("PersonalizationService not available, using default bible") + return self._get_default_bible(project_id) + + try: + preferences = self.personalization_service.get_user_preferences(user_id) + except Exception as pref_err: + logger.warning(f"Failed to get user preferences: {pref_err}, using defaults") + return self._get_default_bible(project_id) + + if not preferences: + logger.info(f"No preferences found for user {user_id}, using defaults") + return self._get_default_bible(project_id) if not isinstance(preferences, dict): logger.warning(f"Podcast Bible preferences payload is non-dict for user {user_id}, using defaults") preferences = {} @@ -129,18 +146,23 @@ class PodcastBibleService: name="AI Host", background="Industry Professional", expertise_level="Expert", + personality_traits=["Professional", "Informative"], vocal_style="Authoritative", - vocal_characteristics=["Deep", "Steady"] + vocal_characteristics=["Deep", "Steady"], + look="A professional individual dressed in business-casual attire." ), audience=AudienceDNA( expertise_level="Intermediate", interests=["Industry Trends", "Technology"], - pain_points=["Staying Competitive", "Operational Efficiency"] + pain_points=["Staying Competitive", "Operational Efficiency"], + demographics=None ), brand=BrandDNA( industry="General Business", tone="Professional", - communication_style="Analytical" + communication_style="Analytical", + key_messages=[], + competitor_context=None ), visual_style=VisualStyle( environment="Professional modern office studio", diff --git a/backend/services/startup_health.py b/backend/services/startup_health.py index 6c6e1412..2c45cb4d 100644 --- a/backend/services/startup_health.py +++ b/backend/services/startup_health.py @@ -156,6 +156,12 @@ def _check_production_api_key_loading( if deploy_env == "local": _record_check(checks, "production_api_key_loading", True, "skipped in local deploy mode") return + + # Also skip in podcast-only mode (no production API keys needed) + enabled_features = os.getenv("ALWRITY_ENABLED_FEATURES", "all").strip().lower() + if enabled_features == "podcast": + _record_check(checks, "production_api_key_loading", True, "skipped in podcast-only mode") + return test_tenant_id = os.getenv("ALWRITY_STARTUP_TEST_TENANT_ID", "").strip() if not test_tenant_id: diff --git a/backend/services/story_writer/audio_generation_service.py b/backend/services/story_writer/audio_generation_service.py index b07fc470..9c8faf8f 100644 --- a/backend/services/story_writer/audio_generation_service.py +++ b/backend/services/story_writer/audio_generation_service.py @@ -46,6 +46,7 @@ class StoryAudioGenerationService: return _get_story_media_write_dir("audio", user_id=user_id, db=db) except Exception as e: logger.warning(f"[StoryAudioGeneration] Failed to resolve user workspace path for {user_id}: {e}") + # Don't fall back to default - keep using the already-set output_dir for podcast return self.output_dir def _generate_audio_filename(self, scene_number: int, scene_title: str) -> str: @@ -318,6 +319,7 @@ class StoryAudioGenerationService: text: str, user_id: str, voice_id: str = "Wise_Woman", + custom_voice_id: Optional[str] = None, speed: float = 1.0, volume: float = 1.0, pitch: float = 0.0, @@ -364,6 +366,7 @@ class StoryAudioGenerationService: result = generate_audio( text=text.strip(), voice_id=voice_id, + custom_voice_id=custom_voice_id, speed=speed, volume=volume, pitch=pitch, @@ -378,8 +381,8 @@ class StoryAudioGenerationService: enable_sync_mode=enable_sync_mode, ) - # Determine output directory (user workspace or default) - output_dir = self._get_user_audio_dir(user_id, db) + # Use the output_dir that was set when service was created (already handles podcast vs story) + output_dir = self.output_dir # Save audio to file audio_filename = self._generate_audio_filename(scene_number, scene_title) diff --git a/backend/services/subscription/pricing_service.py b/backend/services/subscription/pricing_service.py index b9326417..3d2ac9ec 100644 --- a/backend/services/subscription/pricing_service.py +++ b/backend/services/subscription/pricing_service.py @@ -442,9 +442,34 @@ class PricingService: "description": "AI Audio Generation default pricing" } ] + + # WaveSpeed LLM Text Generation Pricing (via Cerebras) + wavespeed_llm_pricing = [ + { + "provider": APIProvider.WAVESPEED, + "model_name": "openai/gpt-oss-120b", + "cost_per_input_token": 0.0000006, # $0.60 per 1M input tokens + "cost_per_output_token": 0.0000006, # $0.60 per 1M output tokens + "description": "WaveSpeed GPT-OSS 120B (Cerebras) - Fast text generation" + }, + { + "provider": APIProvider.WAVESPEED, + "model_name": "openai/gpt-oss-120b:cerebras", + "cost_per_input_token": 0.0000006, + "cost_per_output_token": 0.0000006, + "description": "WaveSpeed GPT-OSS 120B (Cerebras) - Fast text generation" + }, + { + "provider": APIProvider.WAVESPEED, + "model_name": "openai/gpt-oss-20b", + "cost_per_input_token": 0.0000002, # $0.20 per 1M input tokens + "cost_per_output_token": 0.0000002, # $0.20 per 1M output tokens + "description": "WaveSpeed GPT-OSS 20B (Cerebras) - Cost-effective text generation" + }, + ] # Combine all pricing data (include video pricing in search_pricing list) - all_pricing = gemini_pricing + openai_pricing + anthropic_pricing + mistral_pricing + search_pricing + all_pricing = gemini_pricing + openai_pricing + anthropic_pricing + mistral_pricing + search_pricing + wavespeed_llm_pricing # Insert or update pricing data for pricing_data in all_pricing: diff --git a/backend/services/wavespeed/client.py b/backend/services/wavespeed/client.py index 8f16b762..5e40e4a6 100644 --- a/backend/services/wavespeed/client.py +++ b/backend/services/wavespeed/client.py @@ -241,6 +241,7 @@ class WaveSpeedClient: self, text: str, voice_id: str, + custom_voice_id: Optional[str] = None, speed: float = 1.0, volume: float = 1.0, pitch: float = 0.0, @@ -255,6 +256,7 @@ class WaveSpeedClient: Args: text: Text to convert to speech (max 10000 characters) voice_id: Voice ID (e.g., "Wise_Woman", "Friendly_Person", etc.) + custom_voice_id: Custom voice clone ID for using cloned voice speed: Speech speed (0.5-2.0, default: 1.0) volume: Speech volume (0.1-10.0, default: 1.0) pitch: Speech pitch (-12 to 12, default: 0.0) @@ -269,6 +271,7 @@ class WaveSpeedClient: return self.speech.generate_speech( text=text, voice_id=voice_id, + custom_voice_id=custom_voice_id, speed=speed, volume=volume, pitch=pitch, diff --git a/backend/services/wavespeed/generators/speech.py b/backend/services/wavespeed/generators/speech.py index 0b230bcf..dca29f2c 100644 --- a/backend/services/wavespeed/generators/speech.py +++ b/backend/services/wavespeed/generators/speech.py @@ -40,6 +40,7 @@ class SpeechGenerator: self, text: str, voice_id: str, + custom_voice_id: Optional[str] = None, speed: float = 1.0, volume: float = 1.0, pitch: float = 0.0, @@ -54,6 +55,7 @@ class SpeechGenerator: Args: text: Text to convert to speech (max 10000 characters) voice_id: Voice ID (e.g., "Wise_Woman", "Friendly_Person", etc.) + custom_voice_id: Custom voice clone ID for using cloned voice speed: Speech speed (0.5-2.0, default: 1.0) volume: Speech volume (0.1-10.0, default: 1.0) pitch: Speech pitch (-12 to 12, default: 0.0) @@ -77,6 +79,11 @@ class SpeechGenerator: if not sanitized_voice_id: raise ValueError("Voice ID cannot be empty after sanitization") + # Sanitize custom_voice_id if provided + sanitized_custom_voice_id = None + if custom_voice_id: + sanitized_custom_voice_id = str(custom_voice_id).strip() or None + # Ensure numeric parameters are proper floats and within valid ranges sanitized_speed = max(0.5, min(2.0, float(speed))) if speed is not None else 1.0 sanitized_volume = max(0.1, min(10.0, float(volume))) if volume is not None else 1.0 @@ -112,6 +119,10 @@ class SpeechGenerator: "enable_sync_mode": bool(enable_sync_mode), } + # Add custom voice clone ID if provided + if sanitized_custom_voice_id: + payload["custom_voice_id"] = sanitized_custom_voice_id + # Add optional parameters with proper type validation optional_params = [ "english_normalization", @@ -179,6 +190,20 @@ class SpeechGenerator: if response.status_code != 200: logger.error(f"[WaveSpeed] Speech generation failed: {response.status_code} {response.text}") + + # Check for custom voice ID specific errors + response_text = response.text.lower() + if "custom_voice" in response_text or "voice_id" in response_text: + raise HTTPException( + status_code=400, + detail={ + "error": "Invalid voice clone ID", + "message": "The custom voice ID is invalid or expired. Please create a new voice clone or use a predefined voice.", + "status_code": response.status_code, + "response": response.text, + }, + ) + raise HTTPException( status_code=502, detail={ diff --git a/backend/services/wavespeed/infinitetalk.py b/backend/services/wavespeed/infinitetalk.py index 61b1752b..0e84254a 100644 --- a/backend/services/wavespeed/infinitetalk.py +++ b/backend/services/wavespeed/infinitetalk.py @@ -26,20 +26,24 @@ def _generate_simple_infinitetalk_prompt( story_context: Dict[str, Any], ) -> Optional[str]: """ - Generate a balanced, concise prompt for InfiniteTalk. - InfiniteTalk is audio-driven, so the prompt should describe the scene and suggest - subtle motion, but avoid overly elaborate cinematic descriptions. + Generate an enhanced prompt for InfiniteTalk video generation. + Includes scene content, analysis, bible context, and visual elements. Returns None if no meaningful prompt can be generated. """ title = (scene_data.get("title") or "").strip() description = (scene_data.get("description") or "").strip() image_prompt = (scene_data.get("image_prompt") or "").strip() + lines = scene_data.get("lines", []) + narration = "" + if lines: + # Combine first few lines for context + narration = " ".join([str(l.get("text", "")) for l in lines[:3]])[:150] - # Build a balanced prompt: scene description + simple motion hint + # Build enhanced prompt with multiple context sources parts = [] - # Add scene context + # Add main scene title if title and len(title) > 5 and title.lower() not in ("scene", "podcast", "episode"): parts.append(title) @@ -48,60 +52,70 @@ def _generate_simple_infinitetalk_prompt( if analysis: content_type = analysis.get("content_type") if content_type: - parts.append(f"Style: {content_type}") + parts.append(f"Content type: {content_type}") - # Audience helps define the formality/vibe + # Add key takeaways if available + key_takeaways = analysis.get("keyTakeaways", []) + if key_takeaways and isinstance(key_takeaways, list) and len(key_takeaways) > 0: + takeaway = str(key_takeaways[0])[:80] + if takeaway: + parts.append(f"Key insight: {takeaway}") + + # Audience audience = analysis.get("audience") if audience: - # Just use first few words of audience to keep it short - short_audience = " ".join(audience.split()[:3]) - parts.append(f"For: {short_audience}") - - # Add bible context if available + short_audience = " ".join(audience.split()[:3]) + parts.append(f"Target audience: {short_audience}") + + # Guest info + guest_name = analysis.get("guestName") + guest_expertise = analysis.get("guestExpertise") + if guest_name: + parts.append(f"Guest: {guest_name}") + if guest_expertise: + parts.append(f"Expertise: {guest_expertise}") + + # Add bible context bible = story_context.get("bible", {}) if bible: host_persona = bible.get("host_persona") tone = bible.get("tone") + visual_style = bible.get("visual_style") + background = bible.get("background") + if host_persona: - parts.append(f"Host: {host_persona}") + parts.append(f"Host persona: {host_persona}") if tone: parts.append(f"Tone: {tone}") - - elif description: - # Take first sentence or first 60 chars - desc_part = description.split('.')[0][:60].strip() - if desc_part: - parts.append(desc_part) - elif image_prompt: - # Take first sentence or first 60 chars - img_part = image_prompt.split('.')[0][:60].strip() + if visual_style: + parts.append(f"Visual style: {visual_style}") + if background: + parts.append(f"Background: {background}") + + # Add original image prompt as fallback context + if image_prompt and len(parts) < 3: + img_part = image_prompt.split('.')[0][:100].strip() if img_part: - parts.append(img_part) + parts.append(f"Visual context: {img_part}") + + # Add narration snippet if available + if narration and len(parts) < 4: + parts.append(f"Discussing: {narration}") if not parts: return None - # Add a simple, subtle motion suggestion (not elaborate camera movements) - # Keep it natural and audio-driven - motion_hints = [ - "with subtle movement", - "with gentle motion", - "with natural animation", - ] + # Build prompt with visual quality keywords + quality_keywords = "Cinematic lighting, high detail, 4k quality, smooth motion" - # Combine scene description with subtle motion hint - if len(parts[0]) < 80: - # Room for a motion hint - prompt = f"{parts[0]}, {motion_hints[0]}" - else: - # Just use the description if it's already long enough - prompt = parts[0] + # Combine parts into final prompt + prompt = f"{'. '.join(parts)}. {quality_keywords}. With subtle natural movement." - # Keep it concise - max 120 characters (allows for scene + motion hint) - prompt = prompt[:120].strip() + # Allow more room for detailed prompts - max 350 characters + prompt = prompt[:350].strip() - # Clean up trailing commas or incomplete sentences - if prompt.endswith(','): + # Clean up trailing punctuation + if prompt.endswith(',') or prompt.endswith('.'): prompt = prompt[:-1].strip() return prompt if len(prompt) >= 15 else None diff --git a/backend/start_alwrity_backend.py b/backend/start_alwrity_backend.py index 965dfcb0..a41083ac 100644 --- a/backend/start_alwrity_backend.py +++ b/backend/start_alwrity_backend.py @@ -50,6 +50,10 @@ def should_bootstrap_linguistic_models() -> bool: if "all" in enabled_features: return True + # Podcast-only mode doesn't need linguistic models + if enabled_features == {"podcast"}: + return False + # Map old profile names to features for backwards compatibility feature_mapping = { "podcast": "podcast", @@ -64,14 +68,18 @@ def should_bootstrap_linguistic_models() -> bool: def should_bootstrap_local_llm_models() -> bool: - """Decide whether to bootstrap local LLM models based on enabled features.""" + """Decide whether to bootstrap local LLM models based on enabled features. + + SIF/Story Writer requires local LLM - skip if only podcast is enabled. + """ enabled_features = get_enabled_features() if "all" in enabled_features: return True - # Skip LLM bootstrap for lean deployments - return "core" in enabled_features or "podcast" in enabled_features + # SIF/Story Writer requires local LLM - only bootstrap if explicitly needed + # Skip for lean deployments (podcast-only, content-planning only, etc.) + return False # Default to skip unless "all" is enabled def bootstrap_linguistic_models() -> BootstrapResult: @@ -209,6 +217,10 @@ def bootstrap_local_llm_models() -> BootstrapResult: # Bootstrap linguistic models BEFORE any imports that might need them BOOTSTRAP_RESULTS = [] +# Load .env file early so ALWRITY_ENABLED_FEATURES is available +from dotenv import load_dotenv +load_dotenv() + if __name__ == "__main__": enabled_features = get_enabled_features() features_str = ",".join(sorted(enabled_features)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6062b431..5ceede57 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,7 @@ -import React from 'react'; -import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { Box, CircularProgress, Typography } from '@mui/material'; -import { CopilotKit } from "@copilotkit/react-core"; import { ClerkProvider, useAuth } from '@clerk/clerk-react'; -import "@copilotkit/react-ui/styles.css"; -import { shouldSkipOnboarding } from './utils/demoMode'; import Wizard from './components/OnboardingWizard/Wizard'; import MainDashboard from './components/MainDashboard/MainDashboard'; import SEODashboard from './components/SEODashboard/SEODashboard'; @@ -57,18 +54,11 @@ import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallbac import Landing from './components/Landing/Landing'; import ErrorBoundary from './components/shared/ErrorBoundary'; import ErrorBoundaryTest from './components/shared/ErrorBoundaryTest'; -import CopilotKitDegradedBanner from './components/shared/CopilotKitDegradedBanner'; import { OnboardingProvider } from './contexts/OnboardingContext'; -import { SubscriptionProvider, useSubscription } from './contexts/SubscriptionContext'; -import { CopilotKitHealthProvider } from './contexts/CopilotKitHealthContext'; -import { useOAuthTokenAlerts } from './hooks/useOAuthTokenAlerts'; - -import { setAuthTokenGetter, setClerkSignOut } from './api/client'; -import { setMediaAuthTokenGetter } from './utils/fetchMediaBlobUrl'; -import { setBillingAuthTokenGetter } from './services/billingService'; -import { useOnboarding } from './contexts/OnboardingContext'; -import { useState, useEffect } from 'react'; -import ConnectionErrorPage from './components/shared/ConnectionErrorPage'; +import { SubscriptionProvider } from './contexts/SubscriptionContext'; +import InitialRouteHandler from './components/App/InitialRouteHandler'; +import TokenInstaller from './components/App/TokenInstaller'; +import { ConditionalCopilotKit, AuthenticatedCopilotWrapper } from './components/App/CopilotWrappers'; // interface OnboardingStatus { // onboarding_required: boolean; @@ -78,401 +68,6 @@ import ConnectionErrorPage from './components/shared/ConnectionErrorPage'; // completion_percentage?: number; // } -// Conditional CopilotKit wrapper that only shows sidebar on content-planning route -const ConditionalCopilotKit: React.FC<{ children: React.ReactNode }> = ({ children }) => { - // Do not render CopilotSidebar here. Let specific pages/components control it. - return <>{children}; -}; - -// Wrapper to only enable CopilotKit checks/provider when user is authenticated -// This prevents CopilotKit from running on the Landing page -const AuthenticatedCopilotWrapper: React.FC<{ - children: React.ReactNode; - apiKey: string; -}> = ({ children, apiKey }) => { - const { isSignedIn } = useAuth(); - const location = useLocation(); - - // Exclude CopilotKit from running on: - // 1. Landing page (handled by !isSignedIn) - // 2. Onboarding pages (to prevent health check timeouts) - const shouldExcludeCopilot = !isSignedIn || location.pathname.startsWith('/onboarding'); - - if (shouldExcludeCopilot) { - return <>{children}; - } - - const hasKey = apiKey && apiKey.trim(); - - if (hasKey) { - // Enhanced error handler that updates health context - const handleCopilotKitError = (e: any) => { - console.error("CopilotKit Error:", e); - - // Try to get health context if available - // We'll use a custom event to notify health context since we can't access it directly here - const errorMessage = e?.error?.message || e?.message || 'CopilotKit error occurred'; - const errorType = errorMessage.toLowerCase(); - - // Differentiate between fatal and transient errors - const isFatalError = - errorType.includes('cors') || - errorType.includes('ssl') || - errorType.includes('certificate') || - errorType.includes('403') || - errorType.includes('forbidden') || - errorType.includes('ERR_CERT_COMMON_NAME_INVALID'); - - // Dispatch event for health context to listen to - window.dispatchEvent(new CustomEvent('copilotkit-error', { - detail: { - error: e, - errorMessage, - isFatal: isFatalError, - } - })); - }; - - return ( - - - - - Chat Unavailable - - - CopilotKit encountered an error. The app continues to work with manual controls. - - - } - > - - {children} - - - - ); - } - - return ( - - - {children} - - ); -}; - -// Component to handle initial routing based on subscription and onboarding status -// Flow: Subscription → Onboarding → Dashboard -const InitialRouteHandler: React.FC = () => { - const { loading, error, isOnboardingComplete, initializeOnboarding, data } = useOnboarding(); - const { subscription, loading: subscriptionLoading, checkSubscription } = useSubscription(); - const location = useLocation(); - const [connectionError, setConnectionError] = useState<{ - hasError: boolean; - error: Error | null; - }>({ - hasError: false, - error: null, - }); - - // Poll for OAuth token alerts and show toast notifications - // Only enabled when user is authenticated (has subscription) - useOAuthTokenAlerts({ - enabled: subscription?.active === true, - interval: 60000, // Poll every 1 minute - }); - - // Check subscription on mount (non-blocking - don't wait for it to route) - useEffect(() => { - // Delay subscription check slightly to allow auth token getter to be installed first - const timeoutId = setTimeout(async () => { - // Retry logic for initial subscription check - const maxRetries = 3; - for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - await checkSubscription(); - break; // Success - } catch (err) { - console.error(`App: Subscription check attempt ${attempt + 1} failed:`, err); - - // If it's a connection error and we have retries left, wait and retry - const isConnectionError = err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError'); - - if (isConnectionError && attempt < maxRetries - 1) { - const delay = 1000 * Math.pow(2, attempt); // 1s, 2s - await new Promise(resolve => setTimeout(resolve, delay)); - continue; - } - - // If final attempt or not a connection error, handle it - if (attempt === maxRetries - 1 || !isConnectionError) { - if (isConnectionError) { - setConnectionError({ - hasError: true, - error: err as Error, - }); - } - // Don't block routing on other errors - } - } - } - }, 100); // Small delay to ensure TokenInstaller has run - - return () => clearTimeout(timeoutId); - }, []); // Remove checkSubscription dependency to prevent loop - - // Handle post-Stripe-checkout redirect in demo mode - const urlParams = new URLSearchParams(location.search); - const isCheckoutSuccess = urlParams.get('subscription') === 'success'; - - // Initialize onboarding only after subscription is confirmed - useEffect(() => { - if (subscription && !subscriptionLoading) { - // Check if user is new (no subscription record at all) - const isNewUser = !subscription || subscription.plan === 'none'; - - console.log('InitialRouteHandler: Subscription data received:', { - plan: subscription.plan, - active: subscription.active, - isNewUser, - subscriptionLoading - }); - - if (subscription.active && !isNewUser) { - console.log('InitialRouteHandler: Subscription confirmed, initializing onboarding...'); - - // Don't initialize onboarding if checkout was successful - early return handles redirect - if (!isCheckoutSuccess) { - initializeOnboarding(); - } - } - } - }, [subscription, subscriptionLoading, initializeOnboarding, isCheckoutSuccess]); - - // Early return for checkout success in demo mode - if (isCheckoutSuccess && subscription?.active && shouldSkipOnboarding()) { - console.log('InitialRouteHandler: Early redirect - Stripe checkout success in demo mode → Podcast Maker'); - return ; - } - - // Handle connection error - show connection error page - if (connectionError.hasError) { - const handleRetry = () => { - setConnectionError({ - hasError: false, - error: null, - }); - // Re-trigger the subscription check using context - checkSubscription().catch((err) => { - if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) { - setConnectionError({ - hasError: true, - error: err, - }); - } - }); - }; - - const handleGoHome = () => { - window.location.href = '/'; - }; - - return ( - - ); - } - - // Loading state - only wait for onboarding init when user has active subscription - // In demo mode, skip waiting for onboarding data entirely - // This allows no-subscription/inactive flows to continue even when onboarding data is still null. - const isDemoMode = shouldSkipOnboarding(); - const isActiveSubscriber = Boolean(subscription && subscription.active && subscription.plan !== 'none'); - const waitingForOnboardingInit = !isDemoMode && isActiveSubscriber && (loading || !data); - if (waitingForOnboardingInit) { - return ( - - - - Preparing your workspace... - - - ); - } - - // Error state - if (error) { - return ( - - - Error - - - {error} - - - ); - } - - // Decision tree for SIGNED-IN users: - // Priority: Subscription → Onboarding → Dashboard (as per user flow: Landing → Subscription → Onboarding → Dashboard) - - // 1. If subscription is still loading, show loading state - if (subscriptionLoading) { - return ( - - - - Checking subscription... - - - ); - } - - // 2. No subscription data yet - handle gracefully - // If onboarding is complete, allow access to dashboard (user already went through flow) - // If onboarding not complete, check if subscription check is still loading or failed - if (!subscription) { - if (isOnboardingComplete) { - console.log('InitialRouteHandler: Onboarding complete but no subscription data → Dashboard (allow access)'); - return ; - } - - // Onboarding not complete and no subscription data - // If subscription check is still loading, show loading state - if (subscriptionLoading) { - return ( - - - - Checking subscription... - - - ); - } - - // Subscription check completed but returned null/undefined - // In demo mode, allow access to podcast-maker even with no subscription data - if (!subscription) { - if (isOnboardingComplete) { - console.log('InitialRouteHandler: Onboarding complete but no subscription data → Dashboard (allow access)'); - return ; - } - - // Onboarding not complete and no subscription data - // If subscription check is still loading, show loading state - if (subscriptionLoading) { - return ( - - - - Checking subscription... - - - ); - } - - // In demo mode, redirect to podcast-maker even without subscription - if (shouldSkipOnboarding()) { - console.log('InitialRouteHandler: Demo mode - no subscription but allowing access to podcast-maker'); - return ; - } - - // Subscription check completed but returned null/undefined - // This likely means no subscription - redirect to pricing - console.log('InitialRouteHandler: No subscription data after check → Pricing page'); - return ; - } - - // 3. Check subscription status first - const isNewUser = !subscription || subscription.plan === 'none'; - - // No active subscription → Show modal (SubscriptionContext handles this) - // Don't redirect immediately - let the modal show first - // User can click "Renew Subscription" button in modal to go to pricing - // Or click "Maybe Later" to dismiss (but they still can't use features) - if (isNewUser || !subscription.active) { - console.log('InitialRouteHandler: No active subscription - modal will be shown by SubscriptionContext'); - // Note: SubscriptionContext will show the modal automatically when subscription is inactive - // We still redirect to pricing for new users, but allow existing users with expired subscriptions - // to see the modal first. The modal has a "Renew Subscription" button that navigates to pricing. - // For new users (no subscription at all), redirect to pricing immediately - if (isNewUser) { - console.log('InitialRouteHandler: New user (no subscription) → Pricing page'); - return ; - } - // For existing users with inactive subscription, show modal but don't redirect immediately - // The modal will be shown by SubscriptionContext, and user can click "Renew Subscription" - // Allow access to dashboard (modal will be shown and block functionality) - console.log('InitialRouteHandler: Inactive subscription - allowing access to show modal'); - // Continue to onboarding/dashboard flow - modal will be shown by SubscriptionContext - } - - // 4. Has active subscription, check onboarding status - if (!isOnboardingComplete) { - // In demo mode, skip onboarding and go directly to podcast-maker - if (shouldSkipOnboarding()) { - console.log('InitialRouteHandler: Demo mode - skipping onboarding → Podcast Maker'); - return ; - } - console.log('InitialRouteHandler: Subscription active but onboarding incomplete → Onboarding'); - return ; - } - - // 5. Has subscription AND completed onboarding → Dashboard - console.log('InitialRouteHandler: All set (subscription + onboarding) → Dashboard'); - return ; -}; - // Root route that chooses Landing (signed out) or InitialRouteHandler (signed in) const RootRoute: React.FC = () => { const { isSignedIn } = useAuth(); @@ -482,64 +77,6 @@ const RootRoute: React.FC = () => { return ; }; -// Installs Clerk auth token getter into axios clients and stores user_id -// Must render under ClerkProvider -const TokenInstaller: React.FC = () => { - const { getToken, userId, isSignedIn, signOut } = useAuth(); - - // Store user_id in localStorage when user signs in - useEffect(() => { - if (isSignedIn && userId) { - console.log('TokenInstaller: Storing user_id in localStorage:', userId); - localStorage.setItem('user_id', userId); - - // Trigger event to notify SubscriptionContext that user is authenticated - window.dispatchEvent(new CustomEvent('user-authenticated', { detail: { userId } })); - } else if (!isSignedIn) { - // Clear user_id when signed out - console.log('TokenInstaller: Clearing user_id from localStorage'); - localStorage.removeItem('user_id'); - } - }, [isSignedIn, userId]); - - // Install token getter for API calls - useEffect(() => { - const tokenGetter = async () => { - try { - const template = process.env.REACT_APP_CLERK_JWT_TEMPLATE; - // If a template is provided and it's not a placeholder, request a template-specific JWT - if (template && template !== 'your_jwt_template_name_here') { - // @ts-ignore Clerk types allow options object - return await getToken({ template }); - } - return await getToken(); - } catch { - return null; - } - }; - - // Set token getter for main API client - setAuthTokenGetter(tokenGetter); - - // Set token getter for billing API client (same function) - setBillingAuthTokenGetter(tokenGetter); - - // Set token getter for media blob URL fetcher (for authenticated image/video requests) - setMediaAuthTokenGetter(tokenGetter); - }, [getToken]); - - // Install Clerk signOut function for handling expired tokens - useEffect(() => { - if (signOut) { - setClerkSignOut(async () => { - await signOut(); - }); - } - }, [signOut]); - - return null; -}; - const App: React.FC = () => { // React Hooks MUST be at the top before any conditionals const [loading, setLoading] = useState(true); diff --git a/frontend/src/api/brandAssets.ts b/frontend/src/api/brandAssets.ts index 509f57fa..c205b3f3 100644 --- a/frontend/src/api/brandAssets.ts +++ b/frontend/src/api/brandAssets.ts @@ -14,6 +14,7 @@ export interface AssetResponse { export interface VoiceCloneResponse { success: boolean; custom_voice_id?: string; + voice_name?: string; preview_audio_url?: string; asset_id?: number; message?: string; diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel.tsx b/frontend/src/components/PodcastMaker/AnalysisPanel.tsx index 95a91c7a..7bde1257 100644 --- a/frontend/src/components/PodcastMaker/AnalysisPanel.tsx +++ b/frontend/src/components/PodcastMaker/AnalysisPanel.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect } from "react"; -import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress, TextField, IconButton, Select, MenuItem, FormControl, InputLabel, Switch, FormControlLabel } from "@mui/material"; -import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Add as AddIcon, EditNote as EditNoteIcon } from "@mui/icons-material"; +import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress, Button, Checkbox } from "@mui/material"; +import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Add as AddIcon, EditNote as EditNoteIcon, Input as InputIcon, Groups as GroupsIcon, ListAlt as ListAltIcon, RecordVoiceOver as VoiceIcon, Lightbulb as TipsIcon, Quiz as TalkIcon } from "@mui/icons-material"; import { PodcastAnalysis, PodcastEstimate } from "./types"; import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui"; import { Refresh as RefreshIcon } from "@mui/icons-material"; import { aiApiClient } from "../../api/client"; +import { InputsTab, AudienceTab, OutlineTab, TitlesTab, HookTab, TakeawaysTab, GuestTab, CTATab } from "./AnalysisPanel/tabs"; interface AnalysisPanelProps { analysis: PodcastAnalysis | null; @@ -16,6 +17,19 @@ interface AnalysisPanelProps { avatarPrompt?: string | null; onRegenerate?: () => void; onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void; + onRunResearch?: () => void; + isResearchRunning?: boolean; + selectedQueries?: Set; + onToggleQuery?: (queryId: string) => void; + queries?: { id: string; query: string; rationale: string }[]; +} + +type TabId = 'inputs' | 'audience' | 'content' | 'outline' | 'titles' | 'hook' | 'takeaways' | 'cta' | 'guest'; + +interface TabConfig { + id: TabId; + label: string; + icon: React.ReactNode; } const inputStyles = { @@ -54,8 +68,14 @@ export const AnalysisPanel: React.FC = ({ avatarUrl, avatarPrompt, onRegenerate, - onUpdateAnalysis + onUpdateAnalysis, + onRunResearch, + isResearchRunning, + selectedQueries, + onToggleQuery, + queries }) => { + const [activeTab, setActiveTab] = useState('inputs'); const [avatarBlobUrl, setAvatarBlobUrl] = useState(null); const [avatarLoading, setAvatarLoading] = useState(false); const [avatarError, setAvatarError] = useState(false); @@ -64,6 +84,38 @@ export const AnalysisPanel: React.FC = ({ const [isEditing, setIsEditing] = useState(false); const [editedAnalysis, setEditedAnalysis] = useState(null); + const tabs: TabConfig[] = [ + { id: 'inputs', label: 'Your Inputs', icon: }, + { id: 'audience', label: 'Audience', icon: }, + { id: 'content', label: 'Content', icon: }, + { id: 'outline', label: 'Outline', icon: }, + { id: 'titles', label: 'Titles', icon: }, + { id: 'hook', label: 'Hook', icon: }, + { id: 'takeaways', label: 'Takeaways', icon: }, + { id: 'guest', label: 'Guest', icon: }, + { id: 'cta', label: 'CTA', icon: }, + ]; + + const tabButtonStyles = (isActive: boolean) => ({ + background: isActive + ? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)" + : "transparent", + color: isActive ? "#fff" : "#64748b", + border: isActive ? "none" : "1px solid rgba(0,0,0,0.1)", + borderRadius: 2, + px: 2, + py: 1, + fontSize: "0.75rem", + fontWeight: 600, + textTransform: "none" as const, + transition: "all 0.2s ease", + "&:hover": { + background: isActive + ? "linear-gradient(135deg, #764ba2 0%, #667eea 100%)" + : "rgba(102,126,234,0.08)", + }, + }); + // Sync editedAnalysis with analysis initially useEffect(() => { if (analysis && !editedAnalysis) { @@ -325,622 +377,183 @@ export const AnalysisPanel: React.FC = ({ - {/* Inputs Section */} - {(idea || duration || speakers || avatarUrl || avatarPrompt) && ( - <> - - + {tabs.map((tab) => ( + + ))} + + + {/* Tab Content */} + + {activeTab === 'inputs' && ( + + )} + + {activeTab === 'audience' && ( + + )} + + {activeTab === 'outline' && ( + + )} + + {activeTab === 'titles' && ( + + )} + + {activeTab === 'hook' && ( + + )} + + {activeTab === 'takeaways' && ( + + )} + + {activeTab === 'guest' && ( + + )} + + {activeTab === 'cta' && ( + + )} + + + {/* Research Section - Separate from tabs */} + + + + + + + Research Queries + {selectedQueries && selectedQueries.size > 0 && ( + + )} + + {onRunResearch && ( + + ))} + + ); +}; + +export const AnalysisTabContent: React.FC<{ children: React.ReactNode; title?: string; icon?: React.ReactNode }> = ({ + children, + title, + icon, +}) => ( + + {title && ( + + {icon} + {title} + + )} + {children} + +); diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/AudienceTab.tsx b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/AudienceTab.tsx new file mode 100644 index 00000000..f3ee6570 --- /dev/null +++ b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/AudienceTab.tsx @@ -0,0 +1,211 @@ +import React from "react"; +import { Stack, Box, Typography, Chip, TextField, IconButton, Paper, Divider } from "@mui/material"; +import { Groups as GroupsIcon, Insights as InsightsIcon, Search as SearchIcon, EditNote as EditNoteIcon, Add as AddIcon } from "@mui/icons-material"; +import { PodcastAnalysis } from "../../types"; +import { AnalysisTabContent } from "../AnalysisTabNav"; + +interface AudienceTabProps { + analysis: PodcastAnalysis; + isEditing?: boolean; + editedAnalysis?: PodcastAnalysis | null; + setEditedAnalysis?: (analysis: PodcastAnalysis) => void; + handleRemoveKeyword?: (keyword: string) => void; + handleAddKeyword?: (keyword: string) => void; + handleRemoveTitle?: (title: string) => void; + handleAddTitle?: (title: string) => void; + handleUpdateOutline?: (id: string | number, field: 'title' | 'segments', value: any) => void; + updateExaConfig?: (field: string, value: any) => void; +} + +const inputStyles = { + '& .MuiInputBase-input': { color: '#111827 !important', fontWeight: 500 }, + '& .MuiInputLabel-root': { color: '#4b5563 !important' }, + '& .MuiOutlinedInput-root': { + bgcolor: '#ffffff !important', + '& fieldset': { borderColor: '#d1d5db !important' }, + '&:hover fieldset': { borderColor: '#4f46e5 !important' }, + '&.Mui-focused fieldset': { borderColor: '#4f46e5 !important' }, + }, +}; + +export const AudienceTab: React.FC = ({ + analysis, + isEditing, + editedAnalysis, + setEditedAnalysis, + handleRemoveKeyword, + handleAddKeyword, + handleRemoveTitle, + handleAddTitle, + handleUpdateOutline, + updateExaConfig +}) => { + const currentAnalysis = editedAnalysis || analysis; + + return ( + }> + + + + Audience Description + + {isEditing ? ( + setEditedAnalysis?.({ ...currentAnalysis, audience: e.target.value })} + placeholder="Describe your target audience..." + sx={inputStyles} + /> + ) : ( + + {currentAnalysis.audience} + + )} + + + + + Content Type + + {isEditing ? ( + setEditedAnalysis?.({ ...currentAnalysis, contentType: e.target.value })} + placeholder="e.g. Interview, Narrative, Solo..." + sx={inputStyles} + /> + ) : ( + + )} + + + + + Top Keywords + + + {currentAnalysis.topKeywords.map((k: string) => ( + handleRemoveKeyword?.(k) : undefined} + sx={{ + borderColor: "rgba(0,0,0,0.1)", + color: "#0f172a", + background: "#f8fafc", + }} + /> + ))} + + {isEditing && ( + { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddKeyword?.((e.target as HTMLInputElement).value); + (e.target as HTMLInputElement).value = ''; + } + }} + InputProps={{ + endAdornment: ( + { + const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement); + handleAddKeyword?.(input.value); + input.value = ''; + }}> + + + ) + }} + /> + )} + + + {currentAnalysis.exaSuggestedConfig && ( + + + + + Exa Research Config + + + {currentAnalysis.exaSuggestedConfig.exa_search_type && ( + + )} + {currentAnalysis.exaSuggestedConfig.exa_category && ( + + )} + {currentAnalysis.exaSuggestedConfig.date_range && ( + + )} + {currentAnalysis.exaSuggestedConfig.max_sources && ( + + )} + + + )} + + + + Title Suggestions + + + {currentAnalysis.titleSuggestions.map((t: string) => ( + handleRemoveTitle?.(t) : undefined} + sx={{ + color: "#0f172a", + background: "#f8fafc", + maxWidth: "100%", + whiteSpace: "normal", + height: "auto", + }} + /> + ))} + + {isEditing && ( + { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTitle?.((e.target as HTMLInputElement).value); + (e.target as HTMLInputElement).value = ''; + } + }} + InputProps={{ + endAdornment: ( + { + const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement); + handleAddTitle?.(input.value); + input.value = ''; + }}> + + + ) + }} + /> + )} + + + + ); +}; diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/CTATab.tsx b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/CTATab.tsx new file mode 100644 index 00000000..9ee0fa55 --- /dev/null +++ b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/CTATab.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Box, Typography, Paper } from "@mui/material"; +import { Psychology as PsychologyIcon } from "@mui/icons-material"; +import { PodcastAnalysis } from "../../types"; +import { AnalysisTabContent } from "../AnalysisTabNav"; + +interface CTATabProps { + analysis: PodcastAnalysis; +} + +export const CTATab: React.FC = ({ analysis }) => { + if (!analysis.listener_cta) { + return ( + }> + + No listener call-to-action generated yet. + + + ); + } + + return ( + }> + + + {analysis.listener_cta} + + + + This is a call-to-action for listeners to take action after the episode. + + + ); +}; diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/GuestTab.tsx b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/GuestTab.tsx new file mode 100644 index 00000000..0db7043a --- /dev/null +++ b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/GuestTab.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Stack, Box, Typography, Chip, Paper } from "@mui/material"; +import { Quiz as TalkIcon } from "@mui/icons-material"; +import { PodcastAnalysis } from "../../types"; +import { AnalysisTabContent } from "../AnalysisTabNav"; + +interface GuestTabProps { + analysis: PodcastAnalysis; +} + +export const GuestTab: React.FC = ({ analysis }) => { + if (!analysis.guest_talking_points || analysis.guest_talking_points.length === 0) { + return ( + }> + + No guest talking points generated yet. Add a guest speaker to get interview questions. + + + ); + } + + return ( + }> + + {analysis.guest_talking_points.map((point: string, idx: number) => ( + + + + {point} + + + ))} + + + ); +}; diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/HookTab.tsx b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/HookTab.tsx new file mode 100644 index 00000000..ce103437 --- /dev/null +++ b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/HookTab.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Box, Typography, Paper } from "@mui/material"; +import { AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material"; +import { PodcastAnalysis } from "../../types"; +import { AnalysisTabContent } from "../AnalysisTabNav"; + +interface HookTabProps { + analysis: PodcastAnalysis; +} + +export const HookTab: React.FC = ({ analysis }) => { + if (!analysis.episode_hook) { + return ( + }> + + No episode hook generated yet. + + + ); + } + + return ( + }> + + + "{analysis.episode_hook}" + + + + This is a 15-30 second opening hook to grab listener attention. + + + ); +}; diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/InputsTab.tsx b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/InputsTab.tsx new file mode 100644 index 00000000..46921d54 --- /dev/null +++ b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/InputsTab.tsx @@ -0,0 +1,191 @@ +import React from "react"; +import { Box, Stack, Typography, Chip, Paper, CircularProgress, alpha } from "@mui/material"; +import { Input as InputIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material"; +import { AnalysisTabContent } from "../AnalysisTabNav"; + +interface InputsTabProps { + idea?: string; + duration?: number; + speakers?: number; + avatarUrl?: string | null; + avatarPrompt?: string | null; + avatarBlobUrl?: string | null; + avatarLoading?: boolean; + avatarError?: boolean; +} + +export const InputsTab: React.FC = ({ idea, duration, speakers, avatarUrl, avatarPrompt, avatarBlobUrl, avatarLoading, avatarError }) => { + if (!idea && !duration && !speakers && !avatarUrl && !avatarPrompt) { + return null; + } + + return ( + }> + + + {idea && ( + + + Podcast Idea + + + {idea} + + + )} + + {duration !== undefined && ( + + + Duration + + + + )} + {speakers !== undefined && ( + + + Speakers + + + + )} + + + {avatarPrompt && ( + + + + AI Generation Prompt + + + + {avatarPrompt} + + + + )} + + + {avatarUrl && ( + + + + Presenter Avatar + + + {avatarLoading ? ( + + + + ) : avatarError ? ( + + + Failed to load avatar + + + ) : avatarBlobUrl ? ( + + ) : null} + + + )} + + + ); +}; diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/OutlineTab.tsx b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/OutlineTab.tsx new file mode 100644 index 00000000..66bb2228 --- /dev/null +++ b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/OutlineTab.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Stack, Box, Typography, Chip, TextField, IconButton } from "@mui/material"; +import { ListAlt as ListAltIcon, Add as AddIcon } from "@mui/icons-material"; +import { PodcastAnalysis } from "../../types"; +import { AnalysisTabContent } from "../AnalysisTabNav"; + +interface OutlineTabProps { + analysis: PodcastAnalysis; + isEditing?: boolean; + onUpdateOutline?: (id: string | number, field: 'title' | 'segments', value: any) => void; +} + +export const OutlineTab: React.FC = ({ analysis, isEditing, onUpdateOutline }) => { + return ( + }> + + {analysis.suggestedOutlines?.map((outline: { id?: string | number; title: string; segments: string[] }, idx: number) => ( + + + + Option {idx + 1}: {outline.title} + + + + {outline.segments?.map((segment: string, sIdx: number) => ( + + + + {segment} + + + ))} + + + ))} + + + ); +}; diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/ResearchTab.tsx b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/ResearchTab.tsx new file mode 100644 index 00000000..d78b0b83 --- /dev/null +++ b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/ResearchTab.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Stack, Box, Typography, Chip, Paper } from "@mui/material"; +import { Search as SearchIcon } from "@mui/icons-material"; +import { PodcastAnalysis } from "../../types"; +import { AnalysisTabContent } from "../AnalysisTabNav"; + +interface ResearchTabProps { + analysis: PodcastAnalysis; +} + +export const ResearchTab: React.FC = ({ analysis }) => { + if (!analysis.research_queries || analysis.research_queries.length === 0) { + return ( + }> + + No research queries generated yet. + + + ); + } + + return ( + }> + + {analysis.research_queries.map((rq: { query: string; rationale: string }, idx: number) => ( + + + + + + {rq.query} + + + Rationale: {rq.rationale} + + + + + ))} + + + ); +}; diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/TakeawaysTab.tsx b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/TakeawaysTab.tsx new file mode 100644 index 00000000..9b243739 --- /dev/null +++ b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/TakeawaysTab.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Stack, Box, Typography, Chip, Paper } from "@mui/material"; +import { Lightbulb as TipsIcon } from "@mui/icons-material"; +import { PodcastAnalysis } from "../../types"; +import { AnalysisTabContent } from "../AnalysisTabNav"; + +interface TakeawaysTabProps { + analysis: PodcastAnalysis; +} + +export const TakeawaysTab: React.FC = ({ analysis }) => { + if (!analysis.key_takeaways || analysis.key_takeaways.length === 0) { + return ( + }> + + No key takeaways generated yet. + + + ); + } + + return ( + }> + + {analysis.key_takeaways.map((takeaway: string, idx: number) => ( + + + + {takeaway} + + + ))} + + + ); +}; diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/TitlesTab.tsx b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/TitlesTab.tsx new file mode 100644 index 00000000..ddbcc503 --- /dev/null +++ b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/TitlesTab.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { Stack, Box, Typography, Chip, TextField, IconButton } from "@mui/material"; +import { EditNote as EditNoteIcon, Add as AddIcon } from "@mui/icons-material"; +import { PodcastAnalysis } from "../../types"; +import { AnalysisTabContent } from "../AnalysisTabNav"; + +interface TitlesTabProps { + analysis: PodcastAnalysis; + isEditing?: boolean; + handleRemoveTitle?: (title: string) => void; + handleAddTitle?: (title: string) => void; +} + +const inputStyles = { + '& .MuiInputBase-input': { color: '#111827 !important', fontWeight: 500 }, + '& .MuiInputLabel-root': { color: '#4b5563 !important' }, + '& .MuiOutlinedInput-root': { + bgcolor: '#ffffff !important', + '& fieldset': { borderColor: '#d1d5db !important' }, + '&:hover fieldset': { borderColor: '#4f46e5 !important' }, + '&.Mui-focused fieldset': { borderColor: '#4f46e5 !important' }, + }, +}; + +export const TitlesTab: React.FC = ({ analysis, isEditing, handleRemoveTitle, handleAddTitle }) => { + return ( + }> + + + {analysis.titleSuggestions?.map((title: string, idx: number) => ( + handleRemoveTitle?.(title) : undefined} + sx={{ + color: "#0f172a", + background: "#f8fafc", + maxWidth: "100%", + whiteSpace: "normal", + height: "auto", + }} + /> + ))} + + {isEditing && ( + { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTitle?.((e.target as HTMLInputElement).value); + (e.target as HTMLInputElement).value = ''; + } + }} + InputProps={{ + endAdornment: ( + { + const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement); + handleAddTitle?.(input.value); + input.value = ''; + }}> + + + ) + }} + /> + )} + + + ); +}; diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/index.ts b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/index.ts new file mode 100644 index 00000000..b9a1db7b --- /dev/null +++ b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/index.ts @@ -0,0 +1,9 @@ +export { HookTab } from "./HookTab"; +export { CTATab } from "./CTATab"; +export { GuestTab } from "./GuestTab"; +export { TakeawaysTab } from "./TakeawaysTab"; +export { ResearchTab } from "./ResearchTab"; +export { TitlesTab } from "./TitlesTab"; +export { OutlineTab } from "./OutlineTab"; +export { AudienceTab } from "./AudienceTab"; +export { InputsTab } from "./InputsTab"; diff --git a/frontend/src/components/PodcastMaker/CreateModal.tsx b/frontend/src/components/PodcastMaker/CreateModal.tsx index ae9601f4..d6bba11b 100644 --- a/frontend/src/components/PodcastMaker/CreateModal.tsx +++ b/frontend/src/components/PodcastMaker/CreateModal.tsx @@ -5,6 +5,7 @@ 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"; // Imported Components import { CreateHeader } from "./CreateStep/CreateHeader"; @@ -43,6 +44,7 @@ export const CreateModal: React.FC = ({ onCreate, open, defaul const [enhancingTopic, setEnhancingTopic] = useState(false); const [enhanceTopicProgressIndex, setEnhanceTopicProgressIndex] = useState(0); const [knobs, setKnobs] = useState({ ...defaultKnobs }); + const [selectedVoiceId, setSelectedVoiceId] = useState("Wise_Woman"); const [placeholderIndex, setPlaceholderIndex] = useState(0); const [avatarTab, setAvatarTab] = useState(0); const [loadingBrandAvatar, setLoadingBrandAvatar] = useState(false); @@ -318,11 +320,17 @@ export const CreateModal: React.FC = ({ onCreate, open, defaul } } + // Include selected voice in knobs + const finalKnobs = { + ...knobs, + voice_id: selectedVoiceId, + }; + onCreate({ ideaOrUrl: finalUrl || finalIdea, speakers, duration, - knobs, + knobs: finalKnobs, budgetCap, files: { voiceFile, avatarFile }, avatarUrl: finalAvatarUrl, @@ -342,6 +350,7 @@ export const CreateModal: React.FC = ({ onCreate, open, defaul setEnhancingTopic(false); setEnhanceTopicProgressIndex(0); setKnobs({ ...defaultKnobs }); + setSelectedVoiceId("Wise_Woman"); setPlaceholderIndex(0); }; @@ -565,6 +574,12 @@ export const CreateModal: React.FC = ({ onCreate, open, defaul setCameraSelfieOpen={setCameraSelfieOpen} /> + + = ({ - reset, - submit, - canSubmit, - isSubmitting, -}) => { +// ============================================================================ +// Constants & Data +// ============================================================================ + +const ANALYSIS_FEATURES = [ + { icon: , text: "Target audience & content type analysis" }, + { icon: , text: "5 high-impact keywords for discoverability" }, + { icon: , text: "3 catchy episode title suggestions" }, + { icon: , text: "2 detailed episode outlines with segments" }, + { icon: , text: "4-6 research queries for AI-powered research" }, + { icon: , text: "Episode hook, key takeaways & listener CTA" }, +]; + +const ANALYSIS_PROGRESS_STEPS = [ + "Analyzing target audience & content type", + "Generating keywords & title suggestions", + "Creating episode outlines", + "Generating research queries", + "Creating hook, takeaways & CTA", +]; + +const INFO_BANNER_TEXT = + "Podcast avatar Image is required. Brand avatar is default. You can choose from asset library or upload your picture. If not, AI Avatar will be generated automatically."; + +// ============================================================================ +// Styles +// ============================================================================ + +const styles = { + dialog: { + background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)", + border: "1px solid rgba(167, 139, 250, 0.3)", + borderRadius: 3, + }, + infoAlert: { + background: alpha("#f0f4ff", 0.6), + border: "1px solid rgba(99, 102, 241, 0.15)", + borderRadius: 2, + boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)", + }, + progressDot: { + width: 6, + height: 6, + borderRadius: "50%", + bgcolor: "#a78bfa", + }, + dialogContent: { + color: "rgba(255,255,255,0.8)", + minHeight: 200, + py: 3, + }, +}; + +// ============================================================================ +// Sub-Components +// ============================================================================ + +const InfoBanner: React.FC<{ showInfo: boolean; setShowInfo: (v: boolean) => void }> = ({ + showInfo, + setShowInfo, +}) => ( + + } + onClose={() => setShowInfo(false)} + sx={styles.infoAlert} + > + + {INFO_BANNER_TEXT} + + + +); + +const ShowTipsLink: React.FC<{ onClick: () => void }> = ({ onClick }) => ( + + + + Show tips + + +); + +const AnalysisProgressView: React.FC = () => ( + + + + + + + + + + Analyzing Your Podcast Idea + + + + + + + This may take a few moments... + + + {ANALYSIS_PROGRESS_STEPS.map((step, idx) => ( + + {step} + + ))} + + + +); + +const WhatYoullGetView: React.FC = () => ( + <> + + Click "Start Analysis" to begin AI-powered podcast planning. Here's what we'll generate for you: + + + {ANALYSIS_FEATURES.map((feature, index) => ( + + {feature.icon} + + + ))} + + +); + +// ============================================================================ +// Main Component +// ============================================================================ + +export const CreateActions: React.FC = ({ reset, submit, canSubmit, isSubmitting }) => { const [showInfo, setShowInfo] = useState(true); + const [showAnalysisModal, setShowAnalysisModal] = useState(false); + const [analysisStarted, setAnalysisStarted] = useState(false); useEffect(() => { - const timer = setTimeout(() => { - setShowInfo(false); - }, 8000); + const timer = setTimeout(() => setShowInfo(false), 8000); return () => clearTimeout(timer); }, []); + // Close modal when analysis completes + useEffect(() => { + if (!isSubmitting && analysisStarted) { + setShowAnalysisModal(false); + setAnalysisStarted(false); + } + }, [isSubmitting, analysisStarted]); + + const handleSubmitClick = () => { + if (canSubmit && !isSubmitting) setShowAnalysisModal(true); + }; + + const handleStartAnalysis = () => { + setAnalysisStarted(true); + submit(); + }; + + const showProgressInModal = showAnalysisModal && (analysisStarted || isSubmitting); + return ( - {/* Collapsible Info Banner */} - - } - onClose={() => setShowInfo(false)} - sx={{ - background: alpha("#f0f4ff", 0.6), - border: "1px solid rgba(99, 102, 241, 0.15)", - borderRadius: 2, - boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)", - "& .MuiAlert-message": { - width: "100%", - }, - }} - > - - Podcast avatar Image is required. Brand avatar is default. You can choose from asset library or upload your picture. If not, AI Avatar will be generated automatically. - - - - - {!showInfo && ( - - - setShowInfo(true)} - > - Show tips - - - )} + + {!showInfo && setShowInfo(true)} />} }> Reset } @@ -82,6 +230,43 @@ export const CreateActions: React.FC = ({ {isSubmitting ? "Analyzing..." : "Analyze & Continue"} + + !isSubmitting && setShowAnalysisModal(false)} + maxWidth="sm" + fullWidth + PaperProps={{ sx: styles.dialog }} + > + + {isSubmitting ? ( + + + Analyzing Your Podcast Idea + + ) : ( + + + What You'll Get + + )} + + + + {showProgressInModal ? : } + + + + {showProgressInModal ? null : ( + <> + setShowAnalysisModal(false)}>Cancel + }> + Start Analysis + + + )} + + ); }; diff --git a/frontend/src/components/PodcastMaker/FactCard.tsx b/frontend/src/components/PodcastMaker/FactCard.tsx index 70a109ce..14b76b0e 100644 --- a/frontend/src/components/PodcastMaker/FactCard.tsx +++ b/frontend/src/components/PodcastMaker/FactCard.tsx @@ -3,6 +3,7 @@ import { Stack, Typography, Divider, Chip, Tooltip, IconButton, alpha, Box } fro import { OpenInNew as OpenInNewIcon, ContentCopy as ContentCopyIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon } from "@mui/icons-material"; import { Fact } from "./types"; import { GlassyCard, glassyCardSx } from "./ui"; +import { TextToSpeechButton } from "../shared/TextToSpeechButton"; interface FactCardProps { fact: Fact; @@ -162,6 +163,7 @@ export const FactCard: React.FC = ({ fact }) => { + {/* Confidence and Date */} diff --git a/frontend/src/components/PodcastMaker/PodcastBiblePanel.tsx b/frontend/src/components/PodcastMaker/PodcastBiblePanel.tsx index eadca443..b99da21c 100644 --- a/frontend/src/components/PodcastMaker/PodcastBiblePanel.tsx +++ b/frontend/src/components/PodcastMaker/PodcastBiblePanel.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Box, Typography, @@ -26,6 +26,8 @@ interface PodcastBiblePanelProps { } export const PodcastBiblePanel: React.FC = ({ bible, onUpdate }) => { + const [panelExpanded, setPanelExpanded] = useState(false); + if (!bible) return null; const handleUpdateHost = (field: string, value: any) => { @@ -51,136 +53,157 @@ export const PodcastBiblePanel: React.FC = ({ bible, onU return ( - - - - Podcast Bible - - - - - - - + setPanelExpanded(!panelExpanded)} + sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }} + > + } + sx={{ + bgcolor: panelExpanded ? 'rgba(99,102,241,0.05)' : 'transparent', + borderRadius: panelExpanded ? '16px 16px 0 0' : 2, + }} + > + + + + Podcast Bible + + {!panelExpanded && ( + + Host • Audience • Brand + + )} + + e.stopPropagation()}> + + + + + + + + + {/* Host Persona */} + + }> + + + Host Persona + + + + + handleUpdateHost('background', e.target.value)} + multiline + rows={2} + /> + + handleUpdateHost('expertise_level', e.target.value)} + /> + handleUpdateHost('vocal_style', e.target.value)} + /> + + + + - - {/* Host Persona */} - - }> - - - Host Persona - - - - - handleUpdateHost('background', e.target.value)} - multiline - rows={2} - /> - - handleUpdateHost('expertise_level', e.target.value)} - /> - handleUpdateHost('vocal_style', e.target.value)} - /> - - - - + {/* Audience DNA */} + + }> + + + Audience DNA + + + + + handleUpdateAudience('expertise_level', e.target.value)} + /> + + + Interests + + + {bible.audience?.interests?.map((interest: string, idx: number) => ( + + ))} + + + + + Pain Points + + + {bible.audience?.pain_points?.map((point: string, idx: number) => ( + + ))} + + + + + - {/* Audience DNA */} - - }> - - - Audience DNA - - - - - handleUpdateAudience('expertise_level', e.target.value)} - /> - - - Interests - - - {bible.audience?.interests?.map((interest: string, idx: number) => ( - - ))} - - - - - Pain Points - - - {bible.audience?.pain_points?.map((point: string, idx: number) => ( - - ))} - - - - - - - {/* Brand DNA */} - - }> - - - Brand DNA - - - - - handleUpdateBrand('industry', e.target.value)} - /> - - handleUpdateBrand('tone', e.target.value)} - /> - handleUpdateBrand('communication_style', e.target.value)} - /> - - - - - + {/* Brand DNA */} + + }> + + + Brand DNA + + + + + handleUpdateBrand('industry', e.target.value)} + /> + + handleUpdateBrand('tone', e.target.value)} + /> + handleUpdateBrand('communication_style', e.target.value)} + /> + + + + + + + ); }; diff --git a/frontend/src/components/PodcastMaker/PodcastDashboard.tsx b/frontend/src/components/PodcastMaker/PodcastDashboard.tsx index f67a0672..bb455d8d 100644 --- a/frontend/src/components/PodcastMaker/PodcastDashboard.tsx +++ b/frontend/src/components/PodcastMaker/PodcastDashboard.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback } from "react"; -import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha } from "@mui/material"; +import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from "@mui/material"; import { usePodcastProjectState } from "../../hooks/usePodcastProjectState"; import { CreateModal } from "./CreateModal"; import { AnalysisPanel } from "./AnalysisPanel"; @@ -78,7 +78,7 @@ const PodcastDashboard: React.FC = () => { }, [resetState]); if (showProjectList) { - return ; + return setShowProjectList(false)} />; } return ( @@ -197,19 +197,13 @@ const PodcastDashboard: React.FC = () => { /> )} - {(workflow.isAnalyzing || workflow.isResearching) && ( - } - sx={{ - background: "#fef3c7", - border: "1px solid #fde68a", - }} - > - - {workflow.isAnalyzing ? "Analyzing your idea with AI..." : "Running research... This may take a moment."} - - + {(workflow.isAnalyzing || workflow.isResearching || workflow.isGeneratingScript) && ( + + + + {workflow.isAnalyzing ? "Analyzing your idea with AI..." : workflow.isGeneratingScript ? "Generating script with AI..." : "Running research... This may take a moment."} + + )} {/* Create Modal */} @@ -238,6 +232,11 @@ const PodcastDashboard: React.FC = () => { avatarPrompt={project?.avatarPrompt} onRegenerate={() => setShowRegenModal(true)} onUpdateAnalysis={(updated) => projectState.setAnalysis(updated)} + onRunResearch={() => workflow.handleRunResearch()} + isResearchRunning={workflow.isResearching} + selectedQueries={selectedQueries} + onToggleQuery={workflow.toggleQuery} + queries={queries} /> )} @@ -251,6 +250,11 @@ const PodcastDashboard: React.FC = () => { onToggleQuery={workflow.toggleQuery} onProviderChange={setResearchProvider} onRunResearch={workflow.handleRunResearch} + onRegenerateQueries={workflow.handleRegenerateQueries} + onUpdateQuery={workflow.handleUpdateQuery} + onDeleteQuery={workflow.handleDeleteQuery} + analysis={analysis} + idea={project?.idea || ""} /> )} @@ -259,6 +263,7 @@ const PodcastDashboard: React.FC = () => { research={research} canGenerateScript={workflow.canGenerateScript} onGenerateScript={workflow.handleGenerateScript} + isGeneratingScript={workflow.isGeneratingScript} /> )} @@ -332,6 +337,55 @@ const PodcastDashboard: React.FC = () => { }} isSubmitting={workflow.isAnalyzing} /> + + {/* Duplicate Project Dialog */} + workflow.setShowDuplicateDialog(false)} + maxWidth="sm" + fullWidth + PaperProps={{ + sx: { + background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)", + border: "1px solid rgba(167, 139, 250, 0.3)", + borderRadius: 3, + }, + }} + > + + Duplicate Project Found + + + + A project with a similar idea already exists. You can edit the existing project or create a new one (which will overwrite the previous). + + + Existing project idea: +

+ {workflow.duplicateProjectInfo.idea} +

+
+
+ + + + +
); }; diff --git a/frontend/src/components/PodcastMaker/PodcastDashboard/QuerySelection.tsx b/frontend/src/components/PodcastMaker/PodcastDashboard/QuerySelection.tsx index a5b24996..a405add3 100644 --- a/frontend/src/components/PodcastMaker/PodcastDashboard/QuerySelection.tsx +++ b/frontend/src/components/PodcastMaker/PodcastDashboard/QuerySelection.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { Stack, Typography, @@ -16,11 +16,17 @@ import { MenuItem, Box, alpha, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + IconButton, } from "@mui/material"; -import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material"; +import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, Edit as EditIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon } from "@mui/icons-material"; import { ResearchProvider } from "../../../services/blogWriterApi"; import { Query } from "../types"; -import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui"; +import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "../ui"; interface QuerySelectionProps { queries: Query[]; @@ -30,6 +36,11 @@ interface QuerySelectionProps { onToggleQuery: (id: string) => void; onProviderChange: (provider: ResearchProvider) => void; onRunResearch: () => void; + onRegenerateQueries: (feedback: string) => Promise; + onUpdateQuery: (id: string, newQuery: string, newRationale: string) => void; + onDeleteQuery: (id: string) => void; + analysis: any; + idea: string; } export const QuerySelection: React.FC = ({ @@ -40,9 +51,51 @@ export const QuerySelection: React.FC = ({ onToggleQuery, onProviderChange, onRunResearch, + onRegenerateQueries, + onUpdateQuery, + onDeleteQuery, + analysis, + idea, }) => { + const [showRegenDialog, setShowRegenDialog] = useState(false); + const [regenFeedback, setRegenFeedback] = useState(""); + const [isRegenerating, setIsRegenerating] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editQuery, setEditQuery] = useState(""); + const [editRationale, setEditRationale] = useState(""); const selectedCount = selectedQueries.size; + const handleRegenerate = async () => { + if (!regenFeedback.trim()) return; + setIsRegenerating(true); + try { + await onRegenerateQueries(regenFeedback); + setShowRegenDialog(false); + setRegenFeedback(""); + } finally { + setIsRegenerating(false); + } + }; + + const startEdit = (q: Query) => { + setEditingId(q.id); + setEditQuery(q.query); + setEditRationale(q.rationale); + }; + + const saveEdit = () => { + if (editingId && editQuery.trim()) { + onUpdateQuery(editingId, editQuery.trim(), editRationale.trim()); + setEditingId(null); + } + }; + + const cancelEdit = () => { + setEditingId(null); + setEditQuery(""); + setEditRationale(""); + }; + return ( = ({ > - - - Research Queries - + + + + Research Queries + + + } + onClick={() => setShowRegenDialog(true)} + sx={{ py: 0.5, px: 1.5, fontSize: "0.75rem" }} + > + Regenerate + + + Provider @@ -123,26 +188,70 @@ export const QuerySelection: React.FC = ({ {queries.map((q) => ( - - onToggleQuery(q.id)} - disabled={isResearching} - sx={{ - borderRadius: 2, - mb: 1, - border: "1px solid rgba(0,0,0,0.08)", - background: "#f8fafc", - "&:hover": { background: alpha("#667eea", 0.08) }, - }} - > - - - + + + + + + + + + ) : ( + e.stopPropagation()}> + startEdit(q)} sx={{ color: "#6366f1" }}> + + + onDeleteQuery(q.id)} sx={{ color: "#ef4444" }}> + + + + ) + } + > + {editingId === q.id ? ( + + setEditQuery(e.target.value)} + sx={{ mb: 1 }} + /> + setEditRationale(e.target.value)} + /> + + ) : ( + onToggleQuery(q.id)} + disabled={isResearching} + sx={{ + borderRadius: 2, + mb: 1, + border: "1px solid rgba(0,0,0,0.08)", + background: selectedQueries.has(q.id) ? alpha("#667eea", 0.08) : "#f8fafc", + "&:hover": { background: alpha("#667eea", 0.12) }, + }} + > + + + + )} ))} @@ -163,6 +272,69 @@ export const QuerySelection: React.FC = ({
+ + {/* Regenerate Queries Dialog */} + setShowRegenDialog(false)} + maxWidth="sm" + fullWidth + PaperProps={{ + sx: { + background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)", + border: "1px solid rgba(167, 139, 250, 0.3)", + borderRadius: 3, + }, + }} + > + + + Regenerate Research Queries + + + + Provide custom directions to regenerate research queries. You can specify: + + + + • Specific topics or angles you want to explore + + + • Questions you want answered + + + • Areas where you need more depth + + + setRegenFeedback(e.target.value)} + sx={{ + "& .MuiOutlinedInput-root": { + color: "#fff", + "& fieldset": { borderColor: "rgba(255,255,255,0.2)" }, + "&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" }, + "&.Mui-focused fieldset": { borderColor: "#a78bfa" }, + }, + }} + /> + + + setShowRegenDialog(false)}>Cancel + } + > + Generate New Queries + + + ); }; diff --git a/frontend/src/components/PodcastMaker/PodcastDashboard/ResearchSummary.tsx b/frontend/src/components/PodcastMaker/PodcastDashboard/ResearchSummary.tsx index 78138686..b1da22e3 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 } from "@mui/material"; +import { Stack, Typography, Chip, Divider, Box, alpha, Paper, Stepper, Step, StepLabel, CircularProgress } from "@mui/material"; import { Insights as InsightsIcon, Search as SearchIcon, @@ -7,21 +7,26 @@ import { EditNote as EditNoteIcon, Article as ArticleIcon, AutoAwesome as AutoAwesomeIcon, + ArrowForward as ArrowForwardIcon, + CheckCircle as CheckCircleIcon, } from "@mui/icons-material"; import { Research, ResearchInsight } from "../types"; import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui"; import { FactCard } from "../FactCard"; +import { TextToSpeechButton } from "../../shared/TextToSpeechButton"; interface ResearchSummaryProps { research: Research; canGenerateScript: boolean; onGenerateScript: () => void; + isGeneratingScript?: boolean; } export const ResearchSummary: React.FC = ({ research, canGenerateScript, onGenerateScript, + isGeneratingScript = false, }) => { // Simple markdown-to-HTML converter const renderMarkdown = useCallback((text: string) => { @@ -51,6 +56,34 @@ export const ResearchSummary: React.FC = ({ return ( + {/* Step Indicator */} + + + + } + > + Analysis + + + + + Research + + + + + Script + + + + + Render + + + + + @@ -115,11 +148,31 @@ export const ResearchSummary: React.FC = ({ } + disabled={!canGenerateScript || isGeneratingScript} + startIcon={isGeneratingScript ? : } + endIcon={isGeneratingScript ? undefined : } tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"} + sx={{ + background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + color: "#fff", + fontWeight: 700, + fontSize: "1rem", + px: 4, + py: 1.5, + borderRadius: 2, + textTransform: "none", + boxShadow: "0 4px 14px rgba(102, 126, 234, 0.4)", + "&:hover": { + background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)", + boxShadow: "0 6px 20px rgba(102, 126, 234, 0.5)", + }, + "&:disabled": { + background: "#94a3b8", + boxShadow: "none", + } + }} > - Generate Script + {isGeneratingScript ? "Generating Script..." : "Generate Script to Continue"} @@ -139,6 +192,9 @@ export const ResearchSummary: React.FC = ({ Executive Summary + + + `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; type PodcastProjectStateReturn = ReturnType; @@ -41,18 +44,22 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow setResearchProvider, setBudgetCap, updateRenderJob, + setRenderJobs, initializeProject, setBible, } = projectState; const [isAnalyzing, setIsAnalyzing] = useState(false); const [isResearching, setIsResearching] = useState(false); + const [isGeneratingScript, setIsGeneratingScript] = useState(false); const [announcement, setAnnouncement] = useState(""); const [announcementSeverity, setAnnouncementSeverity] = useState<"info" | "error" | "success">("info"); const [showResumeAlert, setShowResumeAlert] = useState(false); const [showPreflightDialog, setShowPreflightDialog] = useState(false); const [preflightResponse, setPreflightResponse] = useState(null); const [preflightOperationName, setPreflightOperationName] = useState(""); + const [showDuplicateDialog, setShowDuplicateDialog] = useState(false); + const [duplicateProjectInfo, setDuplicateProjectInfo] = useState<{projectId: string; idea: string}>({ projectId: "", idea: "" }); const budgetTracking = useBudgetTracking(budgetCap || 50); const preflightCheck = usePreflightCheck({ @@ -113,7 +120,27 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow // This allows the analysis to be personalized using the Bible context const projectId = project?.id || `podcast_${Date.now()}_${Math.floor(Math.random() * 1000)}`; setAnnouncement("Initializing project and brand context..."); - const dbProject = project ? null : await initializeProject(payload, projectId, avatarUrl); + + let dbProject: any = null; + try { + dbProject = project ? null : await initializeProject(payload, projectId, avatarUrl); + } catch (initError: any) { + const errorStr = initError?.message || ""; + if (errorStr.includes("DUPLICATE_IDEA")) { + try { + const dupData = JSON.parse(errorStr); + const existingId = dupData.existing_project_id; + const existingIdea = dupData.existing_idea; + setAnnouncement(""); + // Throw error to trigger UI modal + throw new Error(`DUPLICATE_IDEA:${existingId}:${existingIdea}`); + } catch (parseErr) { + console.error("Failed to parse duplicate idea error:", parseErr); + } + } + throw initError; + } + const bible = dbProject?.bible || projectState.bible; setAnnouncement(feedback ? "Regenerating analysis using your feedback..." : "Analyzing your idea — AI suggestions incoming"); @@ -131,7 +158,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow analysis: result.analysis, estimate: result.estimate, queries: result.queries, - selected_queries: result.queries.map(q => q.id), + selected_queries: [], // Don't auto-select - user must choose manually avatar_url: result.avatar_url, avatar_prompt: result.avatar_prompt, }); @@ -152,7 +179,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow setAnalysis(result.analysis); setEstimate(result.estimate); setQueries(result.queries); - setSelectedQueries(new Set(result.queries.map((q) => q.id))); + setSelectedQueries(new Set()); // Start with none selected - user must choose manually setKnobs(payload.knobs); setBudgetCap(payload.budgetCap); @@ -192,6 +219,18 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow setAnnouncement("Analysis complete"); } } catch (error: any) { + // Handle duplicate idea error + const errorMessage = error?.message || String(error); + if (errorMessage.startsWith("DUPLICATE_IDEA:")) { + const parts = errorMessage.split(":"); + const existingId = parts[1] || ""; + const existingIdea = parts.slice(2).join(":") || "existing project"; + setAnnouncement(""); + setShowDuplicateDialog(true); + setDuplicateProjectInfo({ projectId: existingId, idea: existingIdea }); + return; + } + if (error?.response?.status === 429 || error?.response?.data?.detail) { const errorDetail = error.response.data.detail; if (typeof errorDetail === 'object' && errorDetail.error && errorDetail.error.includes('limit')) { @@ -240,6 +279,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow setPreflightOperationName("Research"); 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", @@ -261,6 +302,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow setShowRenderQueue(false); try { + console.log('[Research] Starting research with:', { topic: project.idea, approvedQueries, provider: researchProvider }); + console.log('[Research] Calling podcastApi.runResearch...'); const { research: mapped, raw } = await podcastApi.runResearch({ projectId: project.id, topic: project.idea, @@ -273,6 +316,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow setAnnouncement(message); }, }); + console.log('[Research] Response received:', { mapped, raw }); setResearch(mapped); setRawResearch(raw); setAnnouncement("Research complete — review fact cards below"); @@ -281,6 +325,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow ? researchError.message : "Research failed. Please try again or switch to Standard Research."; + console.error('[Research] Error caught:', researchError); if (errorMessage.includes("Exa") || errorMessage.includes("exa")) { setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`); } else if (errorMessage.includes("timeout")) { @@ -321,8 +366,18 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow setScriptData(null); setShowRenderQueue(false); setShowScriptEditor(true); + setIsGeneratingScript(true); + setAnnouncement("Generating script with AI... Creating scenes and dialogue based on your research..."); try { + console.log('[ScriptGen] Starting script generation with:', { + idea: project.idea, + speakers: project.speakers, + duration: project.duration, + hasResearch: !!rawResearch, + hasOutline: !!analysis?.suggestedOutlines?.[0], + }); + const result = await podcastApi.generateScript({ projectId: project.id, idea: project.idea, @@ -331,35 +386,55 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow speakers: project.speakers, durationMinutes: project.duration, bible: projectState.bible, - outline: analysis?.suggestedOutlines?.[0], // Pass the first (possibly refined) outline - analysis: analysis, // Pass full analysis context + outline: analysis?.suggestedOutlines?.[0], + analysis: analysis, + onProgress: (message) => { + console.log('[ScriptGen] Progress:', message); + setAnnouncement(message); + }, }); + console.log('[ScriptGen] Script generated:', { sceneCount: result.scenes?.length }); setScriptData(result); + setIsGeneratingScript(false); + setAnnouncement("Script generated! Review and edit your scenes below."); } catch (error) { + setIsGeneratingScript(false); announceError(setAnnouncement, setAnnouncementSeverityFn, error); } }, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible]) const handleProceedToRendering = useCallback((script: Script) => { + // Clear media cache for all scenes before proceeding to remove old blobs + script.scenes.forEach((scene) => { + clearSceneMediaCache(scene.id); + }); + // Also clear global media cache to ensure clean slate + clearMediaCache(); + + // Clear all render jobs to start fresh (removes old videos/images) + setRenderJobs([]); + setScriptData(script); - if (renderJobs.length === 0) { - script.scenes.forEach((scene) => { - const hasExistingAudio = Boolean(scene.audioUrl); - updateRenderJob(scene.id, { - sceneId: scene.id, - title: scene.title, - status: hasExistingAudio ? ("completed" as const) : ("idle" as const), - progress: hasExistingAudio ? 100 : 0, - previewUrl: null, - finalUrl: hasExistingAudio ? scene.audioUrl : null, - jobId: null, - }); + // Create new render jobs with current script scene data + script.scenes.forEach((scene) => { + const hasExistingAudio = Boolean(scene.audioUrl); + const hasExistingImage = Boolean(scene.imageUrl); + updateRenderJob(scene.id, { + sceneId: scene.id, + title: scene.title, + status: hasExistingAudio ? ("completed" as const) : ("idle" as const), + progress: hasExistingAudio ? 100 : 0, + previewUrl: null, + finalUrl: hasExistingAudio ? scene.audioUrl : null, + imageUrl: hasExistingImage ? scene.imageUrl : null, + videoUrl: null, + jobId: null, }); - } + }); setShowRenderQueue(true); setShowScriptEditor(false); - }, [renderJobs.length, setScriptData, updateRenderJob, setShowRenderQueue, setShowScriptEditor]); + }, [setScriptData, setRenderJobs, updateRenderJob, setShowRenderQueue, setShowScriptEditor]); const toggleQuery = useCallback((id: string) => { if (isResearching) return; @@ -370,6 +445,22 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow setSelectedQueries(next); }, [isResearching, selectedQueries, setSelectedQueries]); + const handleUpdateQuery = useCallback((id: string, newQuery: string, newRationale: string) => { + const updated = queries.map(q => q.id === id ? { ...q, query: newQuery, rationale: newRationale } : q); + setQueries(updated); + }, [queries, setQueries]); + + const handleDeleteQuery = useCallback((id: string) => { + const updated = queries.filter(q => q.id !== id); + setQueries(updated); + // Also remove from selected if it was selected + if (selectedQueries.has(id)) { + const newSelected = new Set(selectedQueries); + newSelected.delete(id); + setSelectedQueries(newSelected); + } + }, [queries, selectedQueries, setQueries, setSelectedQueries]); + const activeStep = useMemo(() => { if (showRenderQueue) return 3; if (showScriptEditor) return 2; @@ -397,6 +488,37 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow await handleCreate(payload, feedback); }, [project, projectState.knobs, projectState.budgetCap, handleCreate]); + // Regenerate only research queries (keeps other sections intact) + const handleRegenerateQueries = useCallback(async (feedback: string) => { + if (!project || !analysis) return; + + setAnnouncement("Regenerating research queries..."); + + try { + const response = await podcastApi.regenerateResearchQueries({ + idea: project.idea, + feedback: feedback, + existing_analysis: analysis, + bible: projectState.bible, + }); + + // Convert to Query format + const newQueries = response.research_queries.map((rq, idx) => ({ + id: createId("q"), + query: rq.query, + rationale: rq.rationale, + needsRecentStats: /202[45]|latest|trend/i.test(rq.query), + })); + + setQueries(newQueries); + setSelectedQueries(new Set()); // Don't auto-select - user must choose manually + setAnnouncement("Research queries regenerated"); + } catch (error) { + console.error("Failed to regenerate queries:", error); + setAnnouncement("Failed to regenerate queries"); + } + }, [project, analysis, projectState.bible, setQueries, setSelectedQueries]); + const setAnnouncementSeverityFn = useCallback((severity: "info" | "error" | "success") => { setAnnouncementSeverity(severity); }, []); @@ -405,12 +527,15 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow // State isAnalyzing, isResearching, + isGeneratingScript, announcement, announcementSeverity, showResumeAlert, showPreflightDialog, preflightResponse, preflightOperationName, + showDuplicateDialog, + duplicateProjectInfo, activeStep, canGenerateScript, // Handlers @@ -425,8 +550,13 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow setShowResumeAlert, setShowPreflightDialog, setPreflightResponse, + setShowDuplicateDialog, + setDuplicateProjectInfo, setResearchProvider, getStepLabel, + handleRegenerateQueries: handleRegenerateQueries, + handleUpdateQuery, + handleDeleteQuery, }; }; diff --git a/frontend/src/components/PodcastMaker/PodcastDashboard/utils.ts b/frontend/src/components/PodcastMaker/PodcastDashboard/utils.ts index 5cfa358a..e2214410 100644 --- a/frontend/src/components/PodcastMaker/PodcastDashboard/utils.ts +++ b/frontend/src/components/PodcastMaker/PodcastDashboard/utils.ts @@ -4,6 +4,8 @@ import { CreateProjectPayload, Knobs } from "../types"; export const DEFAULT_KNOBS: Knobs = { voice_emotion: "neutral", voice_speed: 1, + voice_id: "Wise_Woman", + custom_voice_id: undefined, resolution: "720p", scene_length_target: 45, sample_rate: 24000, diff --git a/frontend/src/components/PodcastMaker/ProjectList.tsx b/frontend/src/components/PodcastMaker/ProjectList.tsx index 9e8e19ff..a859c03b 100644 --- a/frontend/src/components/PodcastMaker/ProjectList.tsx +++ b/frontend/src/components/PodcastMaker/ProjectList.tsx @@ -22,10 +22,12 @@ import { Mic as MicIcon, PlayArrow as PlayArrowIcon, Delete as DeleteIcon, + Edit as EditIcon, Star as StarIcon, StarBorder as StarBorderIcon, Refresh as RefreshIcon, Search as SearchIcon, + ArrowBack as ArrowBackIcon, } from "@mui/icons-material"; import { podcastApi } from "../../services/podcastApi"; import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui"; @@ -45,9 +47,10 @@ interface Project { interface ProjectListProps { onSelectProject: (projectId: string) => void; + onBack?: () => void; } -export const ProjectList: React.FC = ({ onSelectProject }) => { +export const ProjectList: React.FC = ({ onSelectProject, onBack }) => { const navigate = useNavigate(); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); @@ -175,6 +178,9 @@ export const ProjectList: React.FC = ({ onSelectProject }) => + navigate(-1))} startIcon={}> + Back + } disabled={loading}> Refresh @@ -248,7 +254,7 @@ export const ProjectList: React.FC = ({ onSelectProject }) => > - + onSelectProject(project.project_id)} sx={{ cursor: "pointer" }}> {project.idea.length > 100 ? `${project.idea.substring(0, 100)}...` : project.idea} @@ -270,14 +276,25 @@ export const ProjectList: React.FC = ({ onSelectProject }) => - + + + { + e.stopPropagation(); + onSelectProject(project.project_id); + }} + sx={{ color: "#a78bfa" }} + > + + + { e.stopPropagation(); handleToggleFavorite(project.project_id, project.is_favorite); }} - sx={{ color: project.is_favorite ? "#fbbf24" : "rgba(255,255,255,0.5)" }} + sx={{ color: project.is_favorite ? "#fbbf24" : "#a78bfa" }} > {project.is_favorite ? : } @@ -289,7 +306,7 @@ export const ProjectList: React.FC = ({ onSelectProject }) => setProjectToDelete(project.project_id); setDeleteDialogOpen(true); }} - sx={{ color: "rgba(255,255,255,0.5)" }} + sx={{ color: "#ef4444" }} > diff --git a/frontend/src/components/PodcastMaker/RenderQueue.tsx b/frontend/src/components/PodcastMaker/RenderQueue.tsx index 4eac4ac2..e8ae4af3 100644 --- a/frontend/src/components/PodcastMaker/RenderQueue.tsx +++ b/frontend/src/components/PodcastMaker/RenderQueue.tsx @@ -63,6 +63,7 @@ export const RenderQueue: React.FC = ({ knobs, projectId, bible, + analysis, budgetCap, avatarImageUrl, onUpdateJob, diff --git a/frontend/src/components/PodcastMaker/RenderQueue/VideoRegenerateModal.tsx b/frontend/src/components/PodcastMaker/RenderQueue/VideoRegenerateModal.tsx index ac6ebccb..63b66c52 100644 --- a/frontend/src/components/PodcastMaker/RenderQueue/VideoRegenerateModal.tsx +++ b/frontend/src/components/PodcastMaker/RenderQueue/VideoRegenerateModal.tsx @@ -46,33 +46,39 @@ export const VideoRegenerateModal: React.FC = ({ // Use a more intelligent default prompt based on context if available const [prompt, setPrompt] = useState(initialPrompt); - // Update prompt when context changes or modal opens + // Update prompt when modal opens - build enhanced prompt from context useEffect(() => { if (open) { - let smartPrompt = initialPrompt; + // Always build an enhanced prompt from available context + const parts = []; - // If the initial prompt is generic/empty, try to build a better one - if (!smartPrompt || smartPrompt === "Professional podcast scene with subtle movement") { - const parts = []; - - // Add scene context - if (sceneTitle) parts.push(`Scene: ${sceneTitle}`); - - // Add bible/persona context - if (bible?.host_persona) parts.push(`Host Persona: ${bible.host_persona}`); - if (bible?.tone) parts.push(`Tone: ${bible.tone}`); - - // Add analysis context - if (analysis?.content_type) parts.push(`Style: ${analysis.content_type}`); - - // Combine into a descriptive prompt - if (parts.length > 0) { - smartPrompt = `Professional talking head video for podcast. ${parts.join(". ")}. Cinematic lighting, 4k, high detail.`; - } + // Add scene context + if (sceneTitle) parts.push(`Scene: ${sceneTitle}`); + + // Add bible/persona context + if (bible?.host_persona) parts.push(`Host Persona: ${bible.host_persona}`); + if (bible?.tone) parts.push(`Tone: ${bible.tone}`); + if (bible?.visual_style) parts.push(`Visual Style: ${bible.visual_style}`); + if (bible?.background) parts.push(`Background: ${bible.background}`); + + // Add analysis context + if (analysis?.content_type) parts.push(`Content Type: ${analysis.content_type}`); + if (analysis?.audience) parts.push(`Target: ${analysis.audience}`); + if (analysis?.guestName) parts.push(`Guest: ${analysis.guestName}`); + if (analysis?.keyTakeaways?.length) parts.push(`Key: ${analysis.keyTakeaways[0]}`); + + // Build enhanced prompt + let smartPrompt = ""; + if (parts.length > 0) { + smartPrompt = `Professional podcast video. ${parts.join(". ")}. Cinematic lighting, high detail, 4k quality, smooth subtle motion.`; + } else { + // Fallback to initial prompt + smartPrompt = initialPrompt || "Professional podcast scene with subtle movement"; } + setPrompt(smartPrompt); } - }, [open, initialPrompt, sceneTitle, bible, analysis]); + }, [open, sceneTitle, bible, analysis]); const [resolution, setResolution] = useState<"480p" | "720p">(initialResolution); const [seed, setSeed] = useState(initialSeed != null && initialSeed !== -1 ? String(initialSeed) : ""); diff --git a/frontend/src/components/PodcastMaker/RenderQueue/useRenderQueue.ts b/frontend/src/components/PodcastMaker/RenderQueue/useRenderQueue.ts index e0a39d14..9c081b0b 100644 --- a/frontend/src/components/PodcastMaker/RenderQueue/useRenderQueue.ts +++ b/frontend/src/components/PodcastMaker/RenderQueue/useRenderQueue.ts @@ -8,6 +8,7 @@ interface UseRenderQueueProps { knobs: Knobs; projectId: string; bible?: any | null; + analysis?: any | null; budgetCap?: number; avatarImageUrl?: string | null; onUpdateJob: (sceneId: string, updates: Partial) => void; @@ -23,6 +24,7 @@ export const useRenderQueue = ({ knobs, projectId, bible, + analysis, budgetCap, avatarImageUrl, onUpdateJob, @@ -54,27 +56,32 @@ export const useRenderQueue = ({ }; }, []); - // Initialize jobs if empty (audio/image only) + // Initialize jobs if empty (audio/image only) OR sync with script scenes useEffect(() => { - if (jobs.length === 0 && script.scenes.length > 0) { - const initialJobs: Job[] = script.scenes.map((s) => { + // Always sync jobs with script scenes - this ensures render queue shows current audio/image + if (script.scenes.length > 0) { + script.scenes.forEach((s) => { const hasExistingAudio = Boolean(s.audioUrl); - return { + const hasExistingImage = Boolean(s.imageUrl); + const isReady = hasExistingAudio; + + // Create job from scene data + const jobFromScene: Job = { sceneId: s.id, title: s.title, - status: hasExistingAudio ? ("completed" as const) : ("idle" as const), - progress: hasExistingAudio ? 100 : 0, + status: isReady ? ("completed" as const) : ("idle" as const), + progress: isReady ? 100 : 0, previewUrl: null, finalUrl: hasExistingAudio ? s.audioUrl || null : null, - imageUrl: s.imageUrl || null, + imageUrl: hasExistingImage ? s.imageUrl || null : null, jobId: null, }; - }); - initialJobs.forEach((job) => { - onUpdateJob(job.sceneId, job); + + // Update job with scene's audio/image data + onUpdateJob(s.id, jobFromScene); }); } - }, [jobs.length, script.scenes.length, onUpdateJob, script.scenes]); + }, [script.scenes, onUpdateJob]); // Load final video URL from project on mount (for persistence across reloads) useEffect(() => { @@ -95,6 +102,7 @@ export const useRenderQueue = ({ }, [projectId]); // Always try to attach existing videos to scenes (even after reloads) + // But skip if job already has imageUrl - indicates user just came from script phase useEffect(() => { if (script.scenes.length === 0) return; @@ -122,6 +130,23 @@ export const useRenderQueue = ({ const job = jobs.find((j) => j.sceneId === scene.id); + // Skip if job already has imageUrl from script phase - don't override with old video + if (job?.imageUrl) { + console.log("[useRenderQueue] Skipping old video - job has imageUrl from script phase:", scene.id, "imageUrl:", job.imageUrl); + return; + } + + // Job has no imageUrl - this could be from page reload or old state + console.log("[useRenderQueue] Job missing imageUrl, checking for old video:", scene.id, "job:", job); + + // Only attach old video if job has NO content at all (no image, no video, no audio) + // If job has finalUrl (audio) or imageUrl from script phase, don't attach old video + const isJobEmpty = !job || (!job.imageUrl && !job.videoUrl && !job.finalUrl); + if (!isJobEmpty) { + console.log("[useRenderQueue] Skipping old video - job has content already:", scene.id, "job:", job); + return; + } + // Avoid redundant updates if (job?.videoUrl === videoUrl) return; @@ -569,6 +594,9 @@ export const useRenderQueue = ({ audioUrl, avatarImageUrl: sceneImageUrl, bible: bible, + analysis: analysis, // Pass analysis for enhanced prompt + sceneImagePrompt: scene.imagePrompt || undefined, // Original image generation prompt + sceneNarration: scene.lines?.map((l: any) => l.text).join(" ").slice(0, 200) || undefined, resolution: targetResolution, prompt: settings?.prompt || undefined, seed: settings?.seed ?? -1, diff --git a/frontend/src/components/PodcastMaker/ScriptEditor/AudioRegenerateModal.tsx b/frontend/src/components/PodcastMaker/ScriptEditor/AudioRegenerateModal.tsx index ea1e8cc3..ff00b7a7 100644 --- a/frontend/src/components/PodcastMaker/ScriptEditor/AudioRegenerateModal.tsx +++ b/frontend/src/components/PodcastMaker/ScriptEditor/AudioRegenerateModal.tsx @@ -21,9 +21,11 @@ import { } from "@mui/material"; import { HelpOutline as HelpOutlineIcon, Close as CloseIcon } from "@mui/icons-material"; import { PrimaryButton, SecondaryButton } from "../ui"; +import { VoiceSelector } from "../../shared/VoiceSelector"; export type AudioGenerationSettings = { voiceId: string; + customVoiceId?: string; speed: number; volume: number; pitch: number; @@ -156,26 +158,12 @@ export const AudioRegenerateModal: React.FC = ({ - - - + setSettings({ ...settings, voiceId })} + showVoiceClone={true} + disabled={false} + /> {/* Speed / Volume / Pitch */} diff --git a/frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx b/frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx index 3ce71089..f56451aa 100644 --- a/frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx +++ b/frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress, IconButton, Tooltip } from "@mui/material"; +import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress, IconButton, Tooltip, Dialog, DialogContent } from "@mui/material"; import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, @@ -8,6 +8,8 @@ import { PlayArrow as PlayArrowIcon, Image as ImageIcon, Delete as DeleteIcon, + Fullscreen as FullscreenIcon, + Close as CloseIcon, } from "@mui/icons-material"; import { Scene, Line, Knobs } from "../types"; import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui"; @@ -31,6 +33,11 @@ interface SceneEditorProps { idea?: string; // Podcast idea for image generation context avatarUrl?: string | null; // Base avatar URL for consistent scene image generation totalScenes?: number; // Total number of scenes in the script + analysis?: { + audience?: string; + contentType?: string; + topKeywords?: string[]; + } | null; } export const SceneEditor: React.FC = ({ @@ -46,6 +53,7 @@ export const SceneEditor: React.FC = ({ idea, avatarUrl, totalScenes, + analysis, }) => { const [localGenerating, setLocalGenerating] = useState(false); const [generatingImage, setGeneratingImage] = useState(false); @@ -56,8 +64,10 @@ export const SceneEditor: React.FC = ({ const [imageLoading, setImageLoading] = useState(false); const [showRegenerateModal, setShowRegenerateModal] = useState(false); const [showAudioModal, setShowAudioModal] = useState(false); + const [showImagePreview, setShowImagePreview] = useState(false); const [audioSettings, setAudioSettings] = useState({ voiceId: "Wise_Woman", + customVoiceId: undefined, speed: 1.0, volume: 1.0, pitch: 0.0, @@ -300,7 +310,8 @@ export const SceneEditor: React.FC = ({ const effectiveSettings = settings || audioSettings; const result = await podcastApi.renderSceneAudio({ scene: currentScene, - voiceId: effectiveSettings.voiceId || "Wise_Woman", + voiceId: effectiveSettings.voiceId || knobs.voice_id || "Wise_Woman", + customVoiceId: effectiveSettings.customVoiceId || knobs.custom_voice_id, emotion: effectiveSettings.emotion || scene.emotion || knobs.voice_emotion || "neutral", speed: effectiveSettings.speed ?? knobs.voice_speed ?? 1.0, volume: effectiveSettings.volume ?? 1.0, @@ -323,6 +334,24 @@ export const SceneEditor: React.FC = ({ } } catch (error) { console.error("Failed to approve and generate audio:", error); + + // Provide user-friendly error message based on error type + let userMessage = "Failed to generate audio. Please try again."; + + if (error instanceof Error) { + const errorMsg = error.message.toLowerCase(); + + if (errorMsg.includes("429") || errorMsg.includes("quota") || errorMsg.includes("limit")) { + userMessage = "Audio generation limit reached. Please check your subscription and try again."; + } else if (errorMsg.includes("voice") || errorMsg.includes("custom_voice")) { + userMessage = "Invalid voice. Please select a different voice and try again."; + } else if (errorMsg.includes("timeout") || errorMsg.includes("timed out")) { + userMessage = "Audio generation timed out. Please try again."; + } else if (errorMsg.includes("network") || errorMsg.includes("connection")) { + userMessage = "Network error. Please check your connection and try again."; + } + } + // On error, revert approval only if we just approved it in this call if (!wasAlreadyApproved) { onUpdateScene({ ...scene, approved: false, audioUrl: undefined }); @@ -379,11 +408,12 @@ export const SceneEditor: React.FC = ({ sceneId: scene.id, sceneTitle: scene.title, sceneContent: sceneContent, - baseAvatarUrl: avatarUrl || undefined, // Pass base avatar URL for character consistency + sceneEmotion: scene.emotion, + baseAvatarUrl: avatarUrl || undefined, idea: idea, + analysis: analysis || undefined, width: 1024, height: 1024, - // Pass custom settings if provided customPrompt: settings?.prompt, style: settings?.style, renderingSpeed: settings?.renderingSpeed, @@ -398,8 +428,12 @@ export const SceneEditor: React.FC = ({ setImageGenerationStatus("Finalizing image..."); setImageGenerationProgress(95); - // Update scene with image URL - const updatedScene = { ...scene, imageUrl: result.image_url }; + // Update scene with image URL and the prompt used + const updatedScene = { + ...scene, + imageUrl: result.image_url, + imagePrompt: result.image_prompt || undefined, + }; onUpdateScene(updatedScene); const elapsed = Math.floor((Date.now() - startTime) / 1000); @@ -725,11 +759,25 @@ export const SceneEditor: React.FC = ({ : "1px solid rgba(245, 158, 11, 0.2)", }} > - + - + {imageBlobUrl && !imageLoading ? "Image Generated" : "Loading Image..."} + {imageBlobUrl && !imageLoading && ( + + setShowImagePreview(true)} + sx={{ + color: "#667eea", + "&:hover": { background: "rgba(102, 126, 234, 0.1)" }, + }} + > + + + + )} {imageBlobUrl && !imageLoading ? ( = ({ initialSettings={audioSettings} isGenerating={generating} /> + + {/* Full-size Image Preview Modal */} + setShowImagePreview(false)} + maxWidth="lg" + PaperProps={{ + sx: { + background: "rgba(0, 0, 0, 0.9)", + borderRadius: 3, + maxHeight: "90vh", + } + }} + > + + setShowImagePreview(false)} + sx={{ + position: "absolute", + top: 8, + right: 8, + color: "#fff", + background: "rgba(0, 0, 0, 0.5)", + zIndex: 1, + "&:hover": { background: "rgba(0, 0, 0, 0.7)" }, + }} + > + + + + + ); }; diff --git a/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx b/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx index 98d4cd05..558be4ec 100644 --- a/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx +++ b/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx @@ -49,7 +49,7 @@ export const ScriptEditor: React.FC = ({ const [error, setError] = useState(null); const [approvingSceneId, setApprovingSceneId] = useState(null); const [generatingAudioId, setGeneratingAudioId] = useState(null); - const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(true); + const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(false); const [combiningAudio, setCombiningAudio] = useState(false); const [combinedAudioResult, setCombinedAudioResult] = useState<{ url: string; @@ -622,6 +622,7 @@ export const ScriptEditor: React.FC = ({ }} idea={idea} avatarUrl={avatarUrl} + analysis={analysis} /> ))} diff --git a/frontend/src/components/PodcastMaker/types.ts b/frontend/src/components/PodcastMaker/types.ts index 1ab140c2..a000fb3a 100644 --- a/frontend/src/components/PodcastMaker/types.ts +++ b/frontend/src/components/PodcastMaker/types.ts @@ -1,6 +1,8 @@ export type Knobs = { voice_emotion: string; voice_speed: number; + voice_id: string; + custom_voice_id?: string; resolution: string; scene_length_target: number; sample_rate: number; @@ -64,6 +66,7 @@ export type Scene = { emotion?: string; // Scene-specific emotion audioUrl?: string; // Generated audio URL for this scene imageUrl?: string; // Generated image URL for this scene (for video generation) + imagePrompt?: string; // Original image generation prompt for video context }; export type Script = { @@ -104,6 +107,10 @@ export type PodcastAnalysis = { suggestedOutlines: { id: number | string; title: string; segments: string[] }[]; suggestedKnobs: Knobs; titleSuggestions: string[]; + episode_hook?: string; + key_takeaways?: string[]; + guest_talking_points?: string[]; + listener_cta?: string; research_queries?: { query: string; rationale: string }[]; exaSuggestedConfig?: { exa_search_type?: "auto" | "keyword" | "neural"; diff --git a/frontend/src/components/PodcastMaker/ui/PrimaryButton.tsx b/frontend/src/components/PodcastMaker/ui/PrimaryButton.tsx index e7b73a05..bb480206 100644 --- a/frontend/src/components/PodcastMaker/ui/PrimaryButton.tsx +++ b/frontend/src/components/PodcastMaker/ui/PrimaryButton.tsx @@ -7,9 +7,11 @@ interface PrimaryButtonProps { disabled?: boolean; loading?: boolean; startIcon?: React.ReactNode; + endIcon?: React.ReactNode; tooltip?: string; ariaLabel?: string; sx?: SxProps; + size?: "small" | "medium" | "large"; } export const PrimaryButton: React.FC = ({ @@ -18,24 +20,32 @@ export const PrimaryButton: React.FC = ({ disabled = false, loading = false, startIcon, + endIcon, tooltip, ariaLabel, sx, + size = "medium", }) => { + const sizeStyles = { + small: { px: 1.5, py: 0.5, fontSize: "0.75rem" }, + medium: { px: 3, py: 1, fontSize: "0.875rem" }, + large: { px: 4, py: 1.5, fontSize: "1rem" }, + }; + const button = (