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