feat: voice clone audio generation + podcast workspace architecture

- Voice clone integration: When user selects voice clone in Write phase,
  backend uses their uploaded voice sample + scene script text to generate
  audio via qwen3/minimax/cosyvoice voice clone APIs
- Multi-tenant workspace storage: All podcast assets (audio, video, images,
  charts) now use workspace-specific directories per user
- Chart preview improvements: Card-based B-Roll charts UI with thumbnails,
  takeaway text, and action buttons; public endpoint for image serving
- Voice clone caching: In-memory LRU cache for voice samples (avoids
  re-downloading per scene); frontend caches voice clone metadata
- Thread pool for voice clone: Audio generation uses ThreadPoolExecutor to
  avoid blocking the FastAPI event loop
- Auto-detect voice clone IDs (vc_*, MY_VOICE_CLONE) to route correctly
- DB fallback for voice sample URL: Fetches from ContentAsset if not passed
- Fixed API URL resolution for chart previews
- Fixed GlassyCard DOM warnings for motion props
- Fixed ScriptGenerationProgressView syntax error
- Fixed usePodcastWorkflow scriptData reference
This commit is contained in:
ajaysi
2026-04-21 19:38:50 +05:30
parent 7637babd7d
commit 91b2f996fd
33 changed files with 1642 additions and 457 deletions

View File

@@ -10,22 +10,32 @@ from loguru import logger
from services.story_writer.audio_generation_service import StoryAudioGenerationService from services.story_writer.audio_generation_service import StoryAudioGenerationService
# Directory paths # Directory paths
# router.py is at: backend/api/podcast/router.py # Find root by looking for 'data' or 'backend' folder
# parents[0] = backend/api/podcast/ def _find_root() -> Path:
# parents[1] = backend/api/ """Find project root by searching up for data directory."""
# parents[2] = backend/ current = Path(__file__).resolve()
# parents[3] = root/ for _ in range(10): # max 10 levels up
ROOT_DIR = Path(__file__).resolve().parents[3] # root/ if (current / "data").exists() and (current / "data" / "media").exists():
DATA_MEDIA_DIR = ROOT_DIR / "data" / "media" 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() ROOT_DIR = _find_root()
PODCAST_IMAGES_DIR = (DATA_MEDIA_DIR / "podcast_images").resolve()
PODCAST_VIDEOS_DIR = (DATA_MEDIA_DIR / "podcast_videos").resolve()
# Video subdirectory # Video subdirectory (relative to workspace media dir)
AI_VIDEO_SUBDIR = Path("AI_Videos") 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: def _sanitize_user_id(user_id: str) -> str:
@@ -38,21 +48,31 @@ def get_podcast_media_dir(
*, *,
ensure_exists: bool = False, ensure_exists: bool = False,
) -> Path: ) -> 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 = { media_subdir = {
"audio": "podcast_audio", "audio": "podcast_audio",
"image": "podcast_images", "image": "podcast_images",
"video": "podcast_videos", "video": "podcast_videos",
"chart": "podcast_charts",
}[media_type] }[media_type]
if user_id: if user_id:
sanitized = _sanitize_user_id(user_id) sanitized = _sanitize_user_id(user_id)
tenant_media_dir = ROOT_DIR / "workspace" / f"workspace_{sanitized}" / "media" / media_subdir resolved_dir = (
resolved_dir = tenant_media_dir.resolve() ROOT_DIR / "workspace" / f"workspace_{sanitized}" / "media" / media_subdir
).resolve()
else: 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: if ensure_exists:
resolved_dir.mkdir(parents=True, exist_ok=True) 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]: 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] = [] Return directories to search for podcast media.
if user_id: Now workspace-only (no legacy fallback).
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}") return [get_podcast_media_dir(media_type, user_id)]
dirs.append(get_podcast_media_dir(media_type, None))
logger.debug(f"[Podcast] get_podcast_media_read_dirs: dirs={dirs}")
return dirs
def get_podcast_audio_service(user_id: str | None = None) -> StoryAudioGenerationService: def get_podcast_audio_service(user_id: str | None = None) -> StoryAudioGenerationService:

View File

@@ -20,7 +20,7 @@ from services.podcast_bible_service import PodcastBibleService
from utils.asset_tracker import save_asset_to_library from utils.asset_tracker import save_asset_to_library
from loguru import logger from loguru import logger
import os import os
from ..constants import PODCAST_IMAGES_DIR from ..constants import get_podcast_media_dir
from ..models import ( from ..models import (
PodcastAnalyzeRequest, PodcastAnalyzeRequest,
PodcastAnalyzeResponse, PodcastAnalyzeResponse,
@@ -247,7 +247,8 @@ async def analyze_podcast_idea(
if image_result and image_result.image_bytes: if image_result and image_result.image_bytes:
img_id = str(uuid.uuid4())[:8] img_id = str(uuid.uuid4())[:8]
filename = f"presenter_podcast_{user_id}_{img_id}.png" 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) avatars_dir.mkdir(parents=True, exist_ok=True)
output_path = avatars_dir / filename output_path = avatars_dir / filename

View File

@@ -12,7 +12,15 @@ from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
import tempfile import tempfile
import uuid import uuid
import hashlib
import time
import shutil 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 services.database import get_db
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
@@ -31,6 +39,124 @@ from ..models import (
router = APIRouter() 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") @router.post("/audio/upload")
async def upload_podcast_audio( async def upload_podcast_audio(
@@ -125,35 +251,176 @@ async def generate_podcast_audio(
raise HTTPException(status_code=400, detail="Text is required") raise HTTPException(status_code=400, detail="Text is required")
try: try:
audio_service = get_podcast_audio_service(user_id) # Determine if we should use voice clone path
logger.warning(f"[Podcast] Generating audio with service dir: {audio_service.output_dir}") # Voice clone is used when: explicitly requested, OR when voice_id/custom_voice_id indicates a clone
result: StoryAudioResult = audio_service.generate_ai_audio( # (cloned voice IDs start with "vc_" or match the placeholder "MY_VOICE_CLONE")
scene_number=0, _vid = request.voice_id or ""
scene_title=request.scene_title, _cvid = request.custom_voice_id or ""
text=request.text.strip(), is_voice_clone = request.use_voice_clone or (
user_id=user_id, _cvid.startswith("vc_") or _cvid == "MY_VOICE_CLONE"
voice_id=request.voice_id or "Wise_Woman", ) or (
custom_voice_id=request.custom_voice_id, _vid.startswith("vc_") or _vid == "MY_VOICE_CLONE"
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 voice_id is a clone ID, normalize it to use Wise_Woman for TTS fallback
if result.get("audio_url") and "/api/story/audio/" in result.get("audio_url", ""): effective_voice_id = _vid if not (_vid.startswith("vc_") or _vid == "MY_VOICE_CLONE") else "Wise_Woman"
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')}") 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: 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}") raise HTTPException(status_code=500, detail=f"Audio generation failed: {exc}")
# Save to asset library (podcast module) # Save to asset library (podcast module)

View File

@@ -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 services.llm_providers.main_image_editing import edit_image
from utils.asset_tracker import save_asset_to_library from utils.asset_tracker import save_asset_to_library
from loguru import logger 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 from ..presenter_personas import choose_persona_id, get_persona
router = APIRouter() router = APIRouter()
# Avatar subdirectory # Avatar subdirectory
AVATAR_SUBDIR = "avatars" AVATAR_SUBDIR = PODCAST_AVATARS_SUBDIR
PODCAST_AVATARS_DIR = PODCAST_IMAGES_DIR / AVATAR_SUBDIR
PODCAST_AVATARS_DIR.mkdir(parents=True, exist_ok=True)
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") @router.post("/avatar/upload")
@@ -57,7 +60,8 @@ async def upload_podcast_avatar(
file_ext = Path(file.filename).suffix or '.png' file_ext = Path(file.filename).suffix or '.png'
unique_id = str(uuid.uuid4())[:8] unique_id = str(uuid.uuid4())[:8]
avatar_filename = f"avatar_{project_id or 'temp'}_{unique_id}{file_ext}" 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 # Save file
with open(avatar_path, "wb") as f: with open(avatar_path, "wb") as f:
@@ -163,7 +167,8 @@ async def make_avatar_presentable(
# Save transformed avatar # Save transformed avatar
unique_id = str(uuid.uuid4())[:8] unique_id = str(uuid.uuid4())[:8]
transformed_filename = f"presenter_transformed_{project_id or 'temp'}_{unique_id}.png" 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: with open(transformed_path, "wb") as f:
f.write(result.image_bytes) f.write(result.image_bytes)
@@ -345,7 +350,8 @@ async def generate_podcast_presenters(
# Save avatar # Save avatar
unique_id = str(uuid.uuid4())[:8] unique_id = str(uuid.uuid4())[:8]
avatar_filename = f"presenter_{project_id or 'temp'}_{i+1}_{unique_id}.png" 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: with open(avatar_path, "wb") as f:
f.write(result.image_bytes) f.write(result.image_bytes)

View File

@@ -191,8 +191,11 @@ async def generate_chart_preview(
""" """
user_id = require_authenticated_user(current_user) 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: try:
broll_service = get_broll_service() broll_service = get_broll_service(user_id=user_id)
chart_id = uuid.uuid4().hex[:8] chart_id = uuid.uuid4().hex[:8]
preview_path = broll_service.generate_chart_preview( preview_path = broll_service.generate_chart_preview(
@@ -203,11 +206,17 @@ async def generate_chart_preview(
chart_id=chart_id, chart_id=chart_id,
) )
# If chart generation failed (empty path), return a placeholder instead of 500
if not preview_path: 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_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( return ChartPreviewResponse(
preview_url=preview_url, preview_url=preview_url,
@@ -324,17 +333,29 @@ async def compose_broll_videos(
async def serve_chart_preview( async def serve_chart_preview(
chart_id: str, chart_id: str,
filename: 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) expected_filename = broll_service.get_chart_preview_filename(chart_id)
if filename != expected_filename: if filename != expected_filename:
raise HTTPException(status_code=404, detail="Chart preview not found") 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(): if not file_path.exists():
raise HTTPException(status_code=404, detail="Chart preview not found") raise HTTPException(status_code=404, detail="Chart preview not found")
@@ -342,7 +363,7 @@ async def serve_chart_preview(
return FileResponse( return FileResponse(
path=str(file_path), path=str(file_path),
media_type="image/png", media_type="image/png",
filename=filename, filename=expected_filename,
) )

View File

@@ -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 services.llm_providers.main_image_generation import generate_image, generate_character_image
from utils.asset_tracker import save_asset_to_library from utils.asset_tracker import save_asset_to_library
from loguru import logger from loguru import logger
from ..constants import PODCAST_IMAGES_DIR from ..constants import get_podcast_media_dir
from ..models import PodcastImageRequest, PodcastImageResponse from ..models import PodcastImageRequest, PodcastImageResponse
router = APIRouter() router = APIRouter()
@@ -377,14 +377,14 @@ async def generate_podcast_scene_image(
user_id=user_id user_id=user_id
) )
# Save image to podcast images directory # Save image to podcast images directory (workspace-aware)
PODCAST_IMAGES_DIR.mkdir(parents=True, exist_ok=True) images_dir = get_podcast_media_dir("image", user_id, ensure_exists=True)
# Generate filename # Generate filename
clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in request.scene_title[:30]) clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in request.scene_title[:30])
unique_id = str(uuid.uuid4())[:8] unique_id = str(uuid.uuid4())[:8]
image_filename = f"scene_{request.scene_id}_{clean_title}_{unique_id}.png" 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 # Save image
with open(image_path, "wb") as f: with open(image_path, "wb") as f:
@@ -470,16 +470,17 @@ async def serve_podcast_image(
Query parameter is useful for HTML elements like <img> that cannot send custom headers. Query parameter is useful for HTML elements like <img> that cannot send custom headers.
Supports subdirectories like avatars/ 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 # Security check: ensure path doesn't contain path traversal or absolute paths
if ".." in path or path.startswith("/"): if ".." in path or path.startswith("/"):
raise HTTPException(status_code=400, detail="Invalid path") 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 # Security check: ensure resolved path is within images_dir
if not str(image_path).startswith(str(PODCAST_IMAGES_DIR)): if not str(image_path).startswith(str(images_dir)):
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
if not image_path.exists(): if not image_path.exists():

View File

@@ -11,6 +11,7 @@ from typing import Optional, Dict, Any
from services.database import get_db from services.database import get_db
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
from services.podcast_service import PodcastService from services.podcast_service import PodcastService
from loguru import logger
from ..models import ( from ..models import (
PodcastProjectResponse, PodcastProjectResponse,
CreateProjectRequest, CreateProjectRequest,
@@ -106,14 +107,21 @@ async def update_project(
current_user: Dict[str, Any] = Depends(get_current_user), current_user: Dict[str, Any] = Depends(get_current_user),
): ):
"""Update a podcast project state.""" """Update a podcast project state."""
import time
start_time = time.time()
try: try:
user_id = current_user.get("user_id") or current_user.get("id") user_id = current_user.get("user_id") or current_user.get("id")
if not user_id: if not user_id:
logger.error(f"[Podcast] update_project: No user_id found in current_user: {current_user}") 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") raise HTTPException(status_code=401, detail="User ID not found")
logger.warning(f"[Podcast] update_project: project_id={project_id}, user_id={user_id}") # Get only field names being updated (not full data to avoid console flooding)
logger.warning(f"[Podcast] update_project: request data: {request.model_dump()}") 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) service = PodcastService(db)
@@ -140,10 +148,15 @@ async def update_project(
updates = request.model_dump(exclude_unset=True) updates = request.model_dump(exclude_unset=True)
project = service.update_project(user_id, project_id, **updates) 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) return PodcastProjectResponse.model_validate(project)
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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)}") raise HTTPException(status_code=500, detail=f"Error updating project: {str(e)}")

View File

@@ -9,6 +9,7 @@ from typing import Dict, Any, List
from types import SimpleNamespace from types import SimpleNamespace
import json import json
import re import re
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy.orm import Session 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. Run podcast research via Exa and then use LLM to extract deep insights.
Uses Podcast Bible and Analysis context for hyper-personalization. Uses Podcast Bible and Analysis context for hyper-personalization.
""" """
start_time = time.time()
user_id = require_authenticated_user(current_user) 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]}...") # Log only essential info, not full request data
logger.warning(f"[Podcast Research] Queries count: {len(request.queries) if request.queries else 0}") 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()] queries = [q.strip() for q in request.queries if q and q.strip()]
@@ -424,6 +427,10 @@ QUALITY STANDARDS:
include_avatar_phase=True, 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( return PodcastExaResearchResponse(
sources=sources_payload, sources=sources_payload,
search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries, search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries,

View File

@@ -9,6 +9,7 @@ from typing import Dict, Any, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import json import json
import re import re
import time
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_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. Generate a podcast script outline (scenes + lines) using podcast-oriented prompting.
""" """
user_id = require_authenticated_user(current_user) user_id = require_authenticated_user(current_user)
logger.warning(f"[ScriptGen] ========== SCRIPT GENERATION START ==========") start_time = time.time()
logger.warning(f"[ScriptGen] Topic: {request.idea[:60]}...") logger.warning(f"[ScriptGen] ===== SCRIPT_GEN_START =====")
logger.warning(f"[ScriptGen] Duration: {request.duration_minutes} min, Speakers: {request.speakers}") 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() 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 [] research_fact_cards = request.research.get("factCards", []) if request.research else []
# Build comprehensive research context for higher-quality scripts # Build comprehensive research context for higher-quality scripts
@@ -400,4 +401,7 @@ COST OPTIMIZATION:
if dropped_empty_lines > 0: if dropped_empty_lines > 0:
logger.warning(f"[ScriptGen] Dropped {dropped_empty_lines} empty lines") 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) return PodcastScriptResponse(scenes=scenes)

View File

@@ -223,6 +223,9 @@ class PodcastAudioRequest(BaseModel):
text: str text: str
voice_id: Optional[str] = "Wise_Woman" voice_id: Optional[str] = "Wise_Woman"
custom_voice_id: Optional[str] = None # Voice clone ID for custom voice 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 speed: Optional[float] = 1.0
volume: Optional[float] = 1.0 volume: Optional[float] = 1.0
pitch: Optional[float] = 0.0 pitch: Optional[float] = 0.0

View File

@@ -12,7 +12,7 @@ import uuid
import os import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List, TYPE_CHECKING
from loguru import logger from loguru import logger
# Import chart generators directly # Import chart generators directly
@@ -34,21 +34,27 @@ from services.podcast.broll_composer import (
class BrollService: class BrollService:
"""Orchestrates B-roll composition for podcast scenes.""" """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. Initialize B-roll service.
Args: 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: if output_dir:
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
else: 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) self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[BrollService] Initialized with output directory: {self.output_dir}") 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: def get_output_path(self, filename: str) -> Path:
"""Get output path for a file.""" """Get output path for a file."""
return self.output_dir / filename return self.output_dir / filename
@@ -84,29 +90,91 @@ class BrollService:
resolved_chart_id = chart_id or uuid.uuid4().hex[:8] resolved_chart_id = chart_id or uuid.uuid4().hex[:8]
out_path = str(self.get_chart_preview_path(resolved_chart_id)) 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: try:
if chart_type == "bar_comparison": 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": 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) make_horizontal_bar(chart_data, out_path, title)
elif chart_type == "line_trend": 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) make_line_trend(chart_data, out_path, title)
elif chart_type == "pie": elif chart_type == "pie":
make_pie_chart(chart_data, out_path, title) labels = chart_data.get("labels", [])
elif chart_type == "pie": 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) make_pie_chart(chart_data, out_path, title)
elif chart_type == "stacked_bar": 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) 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", []) 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: if bullet_points:
make_bullet_overlay(bullet_points, out_path) make_bullet_overlay(bullet_points, out_path)
else: else:
logger.warning("[BrollService] No bullet points provided") logger.warning("[BrollService] No bullet points provided")
return "" return ""
else: else:
logger.warning(f"[BrollService] Unknown chart type: {chart_type}") logger.warning(f"[BrollService] Unknown chart type: {chart_type}, falling back to bar_comparison")
return "" # 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}") logger.info(f"[BrollService] Chart preview generated: {out_path}")
return out_path return out_path
@@ -254,13 +322,21 @@ class BrollService:
logger.warning(f"[BrollService] Failed to remove {file}: {e}") logger.warning(f"[BrollService] Failed to remove {file}: {e}")
# Singleton instance for reuse # Per-user service instances for multi-tenant isolation
_broll_service_instance: Optional[BrollService] = None _broll_service_instances: Dict[str, BrollService] = {}
def get_broll_service(output_dir: Optional[str] = None) -> BrollService: def get_broll_service(output_dir: Optional[str] = None, user_id: Optional[str] = None) -> BrollService:
"""Get or create B-roll service singleton.""" """
global _broll_service_instance Get or create B-roll service for the given user.
if _broll_service_instance is None:
_broll_service_instance = BrollService(output_dir=output_dir) For multi-tenant isolation, pass user_id to get user-specific directory.
return _broll_service_instance """
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]

View File

@@ -86,8 +86,8 @@ class PodcastService:
) -> Optional[PodcastProject]: ) -> Optional[PodcastProject]:
"""Update project fields.""" """Update project fields."""
from loguru import logger from loguru import logger
logger.warning(f"[PodcastService] update_project: user_id={user_id}, project_id={project_id}") updated_fields = list(updates.keys()) if isinstance(updates, dict) else []
logger.warning(f"[PodcastService] update_project: updates={updates}") 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) project = self.get_project(user_id, project_id)
if not project: if not project:

View File

@@ -17,6 +17,7 @@ export interface VoiceCloneResponse {
voice_name?: string; voice_name?: string;
preview_audio_url?: string; preview_audio_url?: string;
asset_id?: number; asset_id?: number;
engine?: string;
message?: string; message?: string;
error?: string; error?: string;
} }

View File

@@ -461,9 +461,11 @@ aiApiClient.interceptors.response.use(
} }
if (error.response.status >= 500) { 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( 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'}`)
); );
} }

View File

@@ -5,7 +5,9 @@ import { useSubscription } from "../../contexts/SubscriptionContext";
import { podcastApi } from "../../services/podcastApi"; import { podcastApi } from "../../services/podcastApi";
import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl"; import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl";
import { getLatestBrandAvatar } from "../../api/brandAssets"; 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 // Imported Components
import { TopicUrlInput, TOPIC_PLACEHOLDERS } from "./CreateStep/TopicUrlInput"; import { TopicUrlInput, TOPIC_PLACEHOLDERS } from "./CreateStep/TopicUrlInput";
@@ -316,9 +318,43 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
} }
// Include selected voice in knobs // 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, ...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 { try {

View File

@@ -384,6 +384,11 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
const [showAnalysisModal, setShowAnalysisModal] = useState(false); const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [analysisStarted, setAnalysisStarted] = useState(false); const [analysisStarted, setAnalysisStarted] = useState(false);
const [progressIndex, setProgressIndex] = useState(0); 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 theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
@@ -393,10 +398,6 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
}, []); }, []);
// Close modal only AFTER analysis fully completes (wait for project/analysis to be set) // 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(() => { useEffect(() => {
// Track if analysis transitioned from true to false (completed) // Track if analysis transitioned from true to false (completed)
const wasSubmitting = prevIsSubmittingRef.current; const wasSubmitting = prevIsSubmittingRef.current;
@@ -424,7 +425,7 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
if (error && showAnalysisModal) { if (error && showAnalysisModal) {
console.warn('[CreateActions] Error detected — keeping modal open:', error); 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 // Sequential progress - increment every few seconds
useEffect(() => { useEffect(() => {

View File

@@ -20,6 +20,7 @@ import {
DEFAULT_KNOBS, DEFAULT_KNOBS,
getStepLabel, getStepLabel,
} from "./PodcastDashboard/index"; } from "./PodcastDashboard/index";
import { ScriptGenerationProgressView } from "./PodcastDashboard/ScriptGenerationProgressView";
const PodcastDashboard: React.FC = () => { const PodcastDashboard: React.FC = () => {
useEffect(() => { useEffect(() => {
@@ -400,6 +401,69 @@ const PodcastDashboard: React.FC = () => {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Script Generation Progress Modal */}
<Dialog
open={workflow.showScriptGenModal}
disableEscapeKeyDown={workflow.isGeneratingScript}
onClose={(event, reason) => {
// Only allow closing if NOT generating and generation hasn't started
if (!workflow.isGeneratingScript && !workflow.scriptGenStarted) {
workflow.setShowScriptGenModal(false);
}
}}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
border: "1px solid rgba(52, 211, 153, 0.3)",
borderRadius: 3,
},
}}
>
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1, fontSize: "1.25rem" }}>
{workflow.isGeneratingScript ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<CircularProgress size={20} sx={{ color: "#34d399" }} />
Generating Your Script
</Box>
) : (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
Script Complete
</Box>
)}
</DialogTitle>
<DialogContent sx={{ color: "rgba(255,255,255,0.8)" }}>
<ScriptGenerationProgressView
currentMessage={workflow.announcement}
progressIndex={workflow.scriptGenProgressIndex}
idea={projectState.project?.idea}
analysis={projectState.analysis}
research={projectState.research}
sourceCount={projectState.research?.sourceCount}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3 }}>
{workflow.isGeneratingScript ? (
<Button
onClick={() => workflow.setShowScriptGenModal(false)}
disabled={workflow.isGeneratingScript}
sx={{ color: "rgba(255,255,255,0.6)" }}
>
Cancel
</Button>
) : (
<Button
onClick={() => workflow.setShowScriptGenModal(false)}
variant="contained"
sx={{ bgcolor: "#34d399", "&:hover": { bgcolor: "#10b981" } }}
>
Continue to Editor
</Button>
)}
</DialogActions>
</Dialog>
</Box> </Box>
); );
}; };

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useCallback } from "react"; import React, { useCallback } from "react";
import { Stack, Typography, Chip, Divider, Box, alpha, Paper, CircularProgress } from "@mui/material"; import { Stack, Typography, Chip, Divider, Box, alpha, Paper, CircularProgress, Tooltip } from "@mui/material";
import { import {
Insights as InsightsIcon, Insights as InsightsIcon,
Search as SearchIcon, Search as SearchIcon,
@@ -7,8 +7,9 @@ import {
Article as ArticleIcon, Article as ArticleIcon,
AutoAwesome as AutoAwesomeIcon, AutoAwesome as AutoAwesomeIcon,
ArrowForward as ArrowForwardIcon, ArrowForward as ArrowForwardIcon,
HelpOutline as HelpOutlineIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { Research, ResearchInsight } from "../types"; import { Research, ResearchInsight, Fact } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui"; import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { FactCard } from "../FactCard"; import { FactCard } from "../FactCard";
import { TextToSpeechButton } from "../../shared/TextToSpeechButton"; import { TextToSpeechButton } from "../../shared/TextToSpeechButton";
@@ -26,6 +27,27 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
onGenerateScript, onGenerateScript,
isGeneratingScript = false, 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 // Simple markdown-to-HTML converter
const renderMarkdown = useCallback((text: string) => { const renderMarkdown = useCallback((text: string) => {
if (!text) return null; if (!text) return null;
@@ -150,7 +172,7 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1rem" }} /> <AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1rem" }} />
Executive Summary Executive Summary
<Box sx={{ ml: 'auto' }}> <Box sx={{ ml: 'auto' }}>
<TextToSpeechButton text={research.summary} size="small" showSettings /> <TextToSpeechButton text={stripMarkdown(research.summary)} size="small" showSettings />
</Box> </Box>
</Typography> </Typography>
<Box sx={{ <Box sx={{
@@ -187,28 +209,75 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
borderRadius: 2, borderRadius: 2,
}} }}
> >
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5 }}> <Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5, width: '100%' }}>
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700, flex: 1 }}>
{insight.title} {insight.title}
</Typography> </Typography>
<TextToSpeechButton text={stripMarkdown(insight.content)} size="small" />
{insight.source_indices && insight.source_indices.length > 0 && ( {insight.source_indices && insight.source_indices.length > 0 && (
<Stack direction="row" spacing={0.5}> <Stack direction="row" spacing={0.5}>
{insight.source_indices.map(sIdx => ( {insight.source_indices.map(sIdx => {
<Chip const source = research.sources?.[sIdx - 1];
key={sIdx} const fact = getSourceFact(sIdx);
label={`S${sIdx}`} return (
size="small" <Tooltip
variant="outlined" key={sIdx}
sx={{ title={
height: 18, <Box sx={{ p: 0.5 }}>
fontSize: '0.65rem', {fact ? (
fontWeight: 700, <Box>
borderColor: alpha("#667eea", 0.3), <Typography variant="caption" display="block" fontWeight={700} sx={{ color: '#A5B4FC', mb: 0.5 }}>
color: "#667eea", Source S{sIdx}
bgcolor: alpha("#667eea", 0.05) </Typography>
}} <Typography variant="body2" sx={{ color: '#fff', fontStyle: 'italic', mb: 1, lineHeight: 1.4 }}>
/> "{fact.quote}"
))} </Typography>
{fact.author && (
<Typography variant="caption" sx={{ color: '#A5B4FC' }}>
{fact.author}
</Typography>
)}
</Box>
) : source ? (
<Box>
<Typography variant="caption" display="block" fontWeight={700} sx={{ color: '#A5B4FC', mb: 0.5 }}>
Source S{sIdx}
</Typography>
<Typography variant="body2" fontWeight={600} sx={{ color: '#fff' }}>
{source.title}
</Typography>
</Box>
) : (
<Typography variant="body2" sx={{ color: '#A5B4FC' }}>No source details</Typography>
)}
</Box>
}
placement="right"
arrow
followCursor
>
<Chip
label={`S${sIdx}`}
size="small"
variant="outlined"
component="a"
href={source?.url || undefined}
target={source?.url ? "_blank" : undefined}
rel={source?.url ? "noopener noreferrer" : undefined}
sx={{
height: 18,
fontSize: '0.65rem',
fontWeight: 700,
borderColor: alpha("#667eea", 0.3),
color: "#667eea",
bgcolor: alpha("#667eea", 0.05),
cursor: source?.url ? 'pointer' : 'default',
textDecoration: 'none',
}}
/>
</Tooltip>
);
})}
</Stack> </Stack>
)} )}
</Stack> </Stack>
@@ -259,17 +328,10 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
{/* Expert Quotes */} {/* Expert Quotes */}
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{
px: 1.5, py: 0.5,
borderRadius: 2,
background: 'linear-gradient(135deg, #8B5CF6 0%, #EC4899 100%)',
color: '#fff',
fontSize: '0.75rem',
fontWeight: 700
}}>
NEW
</Box>
Expert Quotes Expert Quotes
<Tooltip title="Expert quotes extracted from research sources - factual statements from industry experts, studies, or authoritative sources that add credibility to your podcast content." placement="right">
<HelpOutlineIcon sx={{ fontSize: 18, color: "#94A3B8", cursor: 'pointer' }} />
</Tooltip>
</Typography> </Typography>
{research.expertQuotes && research.expertQuotes.length > 0 ? ( {research.expertQuotes && research.expertQuotes.length > 0 ? (
<Stack spacing={1.5}> <Stack spacing={1.5}>
@@ -286,38 +348,69 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
"{quote.quote}" "{quote.quote}"
</Typography> </Typography>
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 0.5 }}>
{sourceUrl ? ( <Tooltip
title={
<Box sx={{ p: 0.5 }}>
{(() => {
const source = research.sources?.[quote.source_index - 1];
const fact = getSourceFact(quote.source_index);
if (fact) {
return (
<Box>
<Typography variant="caption" display="block" fontWeight={700} sx={{ color: '#C4B5FD', mb: 0.5 }}>
Source S{quote.source_index}
</Typography>
<Typography variant="body2" sx={{ color: '#fff', fontStyle: 'italic', mb: 1, lineHeight: 1.4 }}>
"{fact.quote}"
</Typography>
{fact.author && (
<Typography variant="caption" sx={{ color: '#A78BFA' }}>
{fact.author}
</Typography>
)}
</Box>
);
}
if (source) {
return (
<Box>
<Typography variant="caption" display="block" fontWeight={700} sx={{ color: '#C4B5FD', mb: 0.5 }}>
Source S{quote.source_index}
</Typography>
<Typography variant="body2" fontWeight={600} sx={{ color: '#fff', mb: 0.5 }}>
{source.title}
</Typography>
</Box>
);
}
return <Typography variant="body2" sx={{ color: '#A78BFA' }}>No source details</Typography>;
})()}
</Box>
}
placement="right"
arrow
followCursor
>
<Chip <Chip
label={`Source S${quote.source_index}`} label={`Source S${quote.source_index}`}
size="small" size="small"
clickable
component="a" component="a"
href={sourceUrl} href={sourceUrl || undefined}
target="_blank" target={sourceUrl ? "_blank" : undefined}
rel="noopener noreferrer" rel={sourceUrl ? "noopener noreferrer" : undefined}
sx={{ sx={{
background: 'linear-gradient(135deg, #8B5CF6 0%, #7C3AED 100%)', background: 'linear-gradient(135deg, #8B5CF6 0%, #7C3AED 100%)',
color: '#fff', color: '#fff',
fontWeight: 600, fontWeight: 600,
fontSize: '0.7rem', fontSize: '0.7rem',
cursor: 'pointer', cursor: sourceUrl ? 'pointer' : 'default',
textDecoration: 'none',
'&:hover': { '&:hover': {
background: 'linear-gradient(135deg, #7C3AED 0%, #6D28D9 100%)', background: 'linear-gradient(135deg, #7C3AED 0%, #6D28D9 100%)',
}, },
}} }}
/> />
) : ( </Tooltip>
<Chip
label={`Source S${quote.source_index}`}
size="small"
sx={{
background: 'linear-gradient(135deg, #8B5CF6 0%, #7C3AED 100%)',
color: '#fff',
fontWeight: 600,
fontSize: '0.7rem',
}}
/>
)}
</Box> </Box>
</Paper> </Paper>
); );
@@ -333,17 +426,10 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
{/* Listener CTAs */} {/* Listener CTAs */}
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{
px: 1.5, py: 0.5,
borderRadius: 2,
background: 'linear-gradient(135deg, #10B981 0%, #14B8A6 100%)',
color: '#fff',
fontSize: '0.75rem',
fontWeight: 700
}}>
NEW
</Box>
Listener CTAs Listener CTAs
<Tooltip title="Call-to-action suggestions for your listeners - what action should they take after listening to your podcast (e.g., visit a website, subscribe, download resources)." placement="right">
<HelpOutlineIcon sx={{ fontSize: 18, color: "#94A3B8", cursor: 'pointer' }} />
</Tooltip>
</Typography> </Typography>
{research.listenerCta && research.listenerCta.length > 0 ? ( {research.listenerCta && research.listenerCta.length > 0 ? (
<Stack spacing={1.5}> <Stack spacing={1.5}>
@@ -370,17 +456,10 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
{/* Mapped Angles */} {/* Mapped Angles */}
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{
px: 1.5, py: 0.5,
borderRadius: 2,
background: 'linear-gradient(135deg, #0EA5E9 0%, #06B6D4 100%)',
color: '#fff',
fontSize: '0.75rem',
fontWeight: 700
}}>
NEW
</Box>
Mapped Angles Mapped Angles
<Tooltip title="Content angles derived from research - specific topics or viewpoints mapped to your target audience's interests and pain points to create engaging episodes." placement="right">
<HelpOutlineIcon sx={{ fontSize: 18, color: "#94A3B8", cursor: 'pointer' }} />
</Tooltip>
</Typography> </Typography>
{research.mappedAngles && research.mappedAngles.length > 0 ? ( {research.mappedAngles && research.mappedAngles.length > 0 ? (
<Stack spacing={1.5}> <Stack spacing={1.5}>
@@ -405,54 +484,7 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
No mapped angles available yet. No mapped angles available yet.
</Typography> </Typography>
)} )}
</Box> </Box>
{/* Listener CTAs */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700 }}>
Listener CTAs
</Typography>
{research.listenerCta && research.listenerCta.length > 0 ? (
<Stack spacing={1}>
{research.listenerCta.slice(0, 4).map((cta, idx) => (
<Paper key={`cta-${idx}`} elevation={0} sx={{ p: 1.5, border: "1px solid rgba(0,0,0,0.06)", borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: "#334155", lineHeight: 1.55 }}>
{cta}
</Typography>
</Paper>
))}
</Stack>
) : (
<Typography variant="body2" sx={{ color: "#64748b" }}>
No listener CTAs suggested yet.
</Typography>
)}
</Box>
{/* Mapped Angles */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700 }}>
Mapped Angles
</Typography>
{research.mappedAngles && research.mappedAngles.length > 0 ? (
<Stack spacing={1}>
{research.mappedAngles.slice(0, 4).map((angle, idx) => (
<Paper key={`angle-${idx}`} elevation={0} sx={{ p: 1.5, border: "1px solid rgba(0,0,0,0.06)", borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 700, mb: 0.5 }}>
{angle.title || `Angle ${idx + 1}`}
</Typography>
<Typography variant="body2" sx={{ color: "#334155", lineHeight: 1.55 }}>
{angle.why || "No rationale provided."}
</Typography>
</Paper>
))}
</Stack>
) : (
<Typography variant="body2" sx={{ color: "#64748b" }}>
No mapped angles available yet.
</Typography>
)}
</Box>
{/* Search Queries Used */} {/* Search Queries Used */}
{research.searchQueries && research.searchQueries.length > 0 && ( {research.searchQueries && research.searchQueries.length > 0 && (

View File

@@ -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: <BoltIcon />,
color: "#10b981",
},
{
title: "Audience-Targeted",
description: "Dialogue written for your specific target audience",
icon: <PsychologyIcon />,
color: "#a78bfa",
},
{
title: "Optimized for TTS",
description: "Proper pacing and hints for natural text-to-speech output",
icon: <VolumeUpIcon />,
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: <TheaterComedyIcon />,
color: "#34d399",
};
const PODCAST_CREATION_JOURNEY = [
{
phase: "Analyze",
icon: <AutoAwesomeIcon />,
color: "#a78bfa",
description: "AI understands your topic and target audience",
benefit: "Identifies key themes and angles"
},
{
phase: "Research",
icon: <SearchIcon />,
color: "#60a5fa",
description: "Gathers facts, statistics, and latest insights",
benefit: "Evidence-based content"
},
{
phase: "Write Script",
icon: <EditIcon />,
color: "#34d399",
description: "Transforms research into structured script",
benefit: "Factual, engaging content",
isCurrent: true,
},
{
phase: "Final Render",
icon: <VideoLibraryIcon />,
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: <EditIcon /> },
{ label: "Approve Scenes", description: "Mark scenes as ready for rendering", icon: <CheckCircleIcon /> },
{ label: "Regenerate", description: "Regenerate specific scenes if needed", icon: <AutoAwesomeIcon /> },
{ label: "Add Charts", description: "Charts auto-generated from research facts", icon: <FormatListBulletedIcon /> },
];
interface ScriptGenerationProgressViewProps {
currentMessage?: string;
progressIndex: number;
idea?: string;
analysis?: any;
research?: any;
sourceCount?: number;
}
export const ScriptGenerationProgressView: React.FC<ScriptGenerationProgressViewProps> = ({
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 (
<Stack spacing={2}>
{/* Current Status */}
<Box sx={{ textAlign: "center" }}>
<Box sx={{ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<CircularProgress size={isMobile ? 50 : 60} thickness={3} sx={{ color: "#34d399" }} />
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
<EditIcon sx={{ color: "#34d399", fontSize: isMobile ? 20 : 24 }} />
</Box>
</Box>
<Typography variant="subtitle1" sx={{ color: "#34d399", fontWeight: 600, mt: 1, fontSize: isMobile ? "0.85rem" : "0.95rem" }}>
{SCRIPT_GENERATION_MESSAGES[clampedIndex].title}
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mt: 0.5, fontSize: isMobile ? "0.75rem" : "0.85rem", px: 1 }}>
{currentMessage || SCRIPT_GENERATION_MESSAGES[clampedIndex].message}
</Typography>
{currentMessage && (
<Typography variant="caption" sx={{ color: "#10b981", mt: 0.5, display: "block", fontSize: "0.75rem" }}>
{currentMessage}
</Typography>
)}
<LinearProgress
sx={{
width: "100%",
height: 4,
borderRadius: 2,
bgcolor: "rgba(255,255,255,0.1)",
mt: 2,
"& .MuiLinearProgress-bar": { bgcolor: "#34d399", borderRadius: 2 },
}}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.4)", mt: 0.5, display: "block" }}>
Step {clampedIndex + 1} of {SCRIPT_GENERATION_MESSAGES.length}
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* How Prior Phases Are Used */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
How We're Personalizing Your Script
</Typography>
<Stack spacing={1}>
{/* Analysis Context */}
<Box sx={{ p: 1.5, borderRadius: 1.5, bgcolor: "rgba(167, 139, 250, 0.1)", border: "1px solid rgba(167, 139, 250, 0.2)" }}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{ width: 24, height: 24, borderRadius: "50%", bgcolor: "rgba(167, 139, 250, 0.2)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<AutoAwesomeIcon sx={{ color: "#a78bfa", fontSize: 14 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{ color: "#a78bfa", fontWeight: 600, fontSize: "0.75rem", display: "block" }}>
From Analyze Phase
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)", fontSize: "0.7rem", display: "block" }}>
<strong>Audience:</strong> {audience}
</Typography>
{keywords.length > 0 && (
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)", fontSize: "0.7rem", display: "block" }}>
<strong>Keywords:</strong> {keywords.join(", ")}
</Typography>
)}
</Box>
</Stack>
</Box>
{/* Research Context */}
<Box sx={{ p: 1.5, borderRadius: 1.5, bgcolor: "rgba(96, 165, 250, 0.1)", border: "1px solid rgba(96, 165, 250, 0.2)" }}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{ width: 24, height: 24, borderRadius: "50%", bgcolor: "rgba(96, 165, 250, 0.2)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<SearchIcon sx={{ color: "#60a5fa", fontSize: 14 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{ color: "#60a5fa", fontWeight: 600, fontSize: "0.75rem", display: "block" }}>
From Research Phase
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)", fontSize: "0.7rem", display: "block" }}>
<strong>{factCards.length} facts</strong>, <strong>{keyInsights.length} insights</strong>, <strong>{sourceCount || 0} sources</strong>
</Typography>
{searchQueries.length > 0 && (
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)", fontSize: "0.7rem", display: "block" }}>
From {searchQueries.length} research queries
</Typography>
)}
</Box>
</Stack>
</Box>
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* What is a Scene */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
{WHAT_IS_SCENE.title}
</Typography>
<Box sx={{ p: 1.5, borderRadius: 1.5, bgcolor: "rgba(52, 211, 153, 0.1)", border: "1px solid rgba(52, 211, 153, 0.2)" }}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{ width: 28, height: 28, borderRadius: "50%", bgcolor: "rgba(52, 211, 153, 0.2)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
{React.cloneElement(WHAT_IS_SCENE.icon, { sx: { color: WHAT_IS_SCENE.color, fontSize: 16 } })}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.8)", fontSize: "0.8rem", display: "block" }}>
{WHAT_IS_SCENE.definition}
</Typography>
</Box>
</Stack>
</Box>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Sequential Progress Steps */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Script Generation Progress
</Typography>
<Stack spacing={0.5}>
{SCRIPT_GENERATION_MESSAGES.map((msg, idx) => {
const isCompleted = idx < clampedIndex;
const isCurrent = idx === clampedIndex;
return (
<Stack key={idx} direction="row" spacing={1} alignItems="flex-start">
<Box sx={{
width: 18,
height: 18,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: isCompleted ? "#10b981" : isCurrent ? "#34d399" : "rgba(255,255,255,0.1)",
flexShrink: 0,
}}>
{isCompleted ? (
<CheckCircleIcon sx={{ fontSize: 12, color: "#fff" }} />
) : isCurrent ? (
<CircularProgress size={10} sx={{ color: "#fff" }} />
) : (
<Box sx={{ width: 4, height: 4, borderRadius: "50%", bgcolor: "rgba(255,255,255,0.3)" }} />
)}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{
color: isCompleted ? "rgba(255,255,255,0.5)" : isCurrent ? "#34d399" : "rgba(255,255,255,0.6)",
fontWeight: isCurrent ? 600 : 400,
fontSize: "0.75rem",
textDecoration: isCompleted ? "line-through" : "none",
}}>
{msg.title}
</Typography>
</Box>
</Stack>
);
})}
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* What to Expect in Script Editor */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
What's Next: Script Editor
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{SCRIPT_EDITOR_PREVIEW.map((item, idx) => (
<Box key={idx} sx={{ flex: "1 1 45%", minWidth: 100, p: 1.5, borderRadius: 1.5, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", textAlign: "center" }}>
<Stack spacing={0.5}>
<Box sx={{ color: "#a78bfa" }}>{React.cloneElement(item.icon, { sx: { fontSize: 18 } })}</Box>
<Typography variant="caption" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.7rem", display: "block" }}>
{item.label}
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem", display: "block" }}>
{item.description}
</Typography>
</Stack>
</Box>
))}
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Journey Overview */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Your Podcast Journey
</Typography>
<Stack spacing={1}>
{PODCAST_CREATION_JOURNEY.map((phase, idx) => (
<Box key={idx} sx={{
p: 1.5,
borderRadius: 2,
bgcolor: phase.isCurrent ? "rgba(52, 211, 153, 0.1)" : "rgba(255,255,255,0.05)",
border: `1px solid ${phase.isCurrent ? "rgba(52, 211, 153, 0.3)" : "rgba(255,255,255, 0.1)"}`
}}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{
width: 28,
height: 28,
borderRadius: "50%",
bgcolor: phase.isCurrent ? "rgba(52, 211, 153, 0.2)" : `${phase.color}20`,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}>
{React.cloneElement(phase.icon, { sx: { color: phase.isCurrent ? "#34d399" : phase.color, fontSize: 16 } })}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ color: phase.isCurrent ? "#34d399" : "#fff", fontWeight: 600, fontSize: "0.8rem" }}>
{phase.phase} {phase.isCurrent && "◀ In Progress"}
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.7rem", display: "block" }}>
{phase.description}
</Typography>
<Typography variant="caption" sx={{ color: phase.isCurrent ? "#34d399" : phase.color, fontSize: "0.65rem", display: "block", mt: 0.25 }}>
{phase.benefit}
</Typography>
</Box>
</Stack>
</Box>
))}
</Stack>
</Box>
</Stack>
);
};

View File

@@ -62,6 +62,11 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false); const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
const [duplicateProjectInfo, setDuplicateProjectInfo] = useState<{projectId: string; idea: string}>({ projectId: "", idea: "" }); 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 budgetTracking = useBudgetTracking(budgetCap || 50);
const preflightCheck = usePreflightCheck({ const preflightCheck = usePreflightCheck({
onBlocked: (response) => { onBlocked: (response) => {
@@ -94,6 +99,47 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
return undefined; return undefined;
}, [announcement]); }, [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) => { const handleCreate = useCallback(async (payload: CreateProjectPayload, feedback?: string) => {
if (isAnalyzing) return; if (isAnalyzing) return;
setResearch(null); setResearch(null);
@@ -327,20 +373,12 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
return; 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)); const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
console.log('[Research] User selected queries:', Array.from(selectedQueries)); console.log('[Research] User selected queries:', Array.from(selectedQueries));
console.log('[Research] Filtered approvedQueries for API:', approvedQueries.map(q => q.query)); 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 { try {
setIsResearching(true); setIsResearching(true);
@@ -395,45 +433,44 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
} finally { } finally {
setIsResearching(false); 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 // Add a ref to track if we're currently generating to prevent double calls
const isGeneratingRef = useRef(false); const isGeneratingRef = useRef(false);
const handleGenerateScript = useCallback(async () => { const handleGenerateScript = useCallback(async () => {
// Guard against double calls // CRITICAL: Guard against double calls - set IMMEDIATELY to prevent concurrent clicks
if (isGeneratingRef.current) { if (isGeneratingRef.current || isGeneratingScript) {
console.log('[ScriptGen] Already generating, skipping duplicate call'); console.log('[ScriptGen] Already generating, skipping duplicate call');
return; 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) { if (!project || !research) {
setAnnouncement("Project or research missing — cannot generate script"); setAnnouncement("Project or research missing — cannot generate script");
return; return;
} }
// Mark as generating immediately (both ref and state) // Mark as generating immediately BEFORE any async calls (both ref and state)
isGeneratingRef.current = true; isGeneratingRef.current = true;
setIsGeneratingScript(true); setIsGeneratingScript(true);
setPreflightOperationName("Script Generation"); // Show modal IMMEDIATELY to prevent duplicate clicks
const preflightResult = await preflightCheck.check({ setShowScriptGenModal(true);
provider: "gemini", setScriptGenStarted(true);
operation_type: "script_generation", setScriptGenProgressIndex(0);
tokens_requested: 2000, console.log('[ScriptGen] Modal shown, generating ref set');
actual_provider_name: "gemini",
});
if (!preflightResult.can_proceed) { // Note: Preflight is also called inside podcastApi.generateScript (ensurePreflight)
isGeneratingRef.current = false; // Reset on preflight failure // No need to call it twice - the API layer handles it
setIsGeneratingScript(false); // Reset loading state on preflight failure
return;
}
setScriptData(null); setScriptData(null);
setShowRenderQueue(false); setShowRenderQueue(false);
setShowScriptEditor(true);
setAnnouncement("Generating script with AI... Creating scenes and dialogue based on your research..."); setAnnouncement("Generating script with AI... Creating scenes and dialogue based on your research...");
try { try {
@@ -464,6 +501,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
console.log('[ScriptGen] Script generated:', { sceneCount: result.scenes?.length }); console.log('[ScriptGen] Script generated:', { sceneCount: result.scenes?.length });
setScriptData(result); setScriptData(result);
setShowScriptEditor(true); // Open editor after successful generation
setIsGeneratingScript(false); setIsGeneratingScript(false);
setAnnouncement("Script generated! Review and edit your scenes below."); setAnnouncement("Script generated! Review and edit your scenes below.");
} catch (error) { } catch (error) {
@@ -472,7 +510,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
} finally { } finally {
isGeneratingRef.current = false; // Reset when done 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) => { const handleProceedToRendering = useCallback((script: Script) => {
// Clear media cache for all scenes before proceeding to remove old blobs // Clear media cache for all scenes before proceeding to remove old blobs
@@ -608,6 +646,11 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
duplicateProjectInfo, duplicateProjectInfo,
activeStep, activeStep,
canGenerateScript, canGenerateScript,
// Script Generation Modal
showScriptGenModal,
setShowScriptGenModal,
scriptGenStarted,
scriptGenProgressIndex,
// Handlers // Handlers
handleCreate, handleCreate,
handleRegenerate, handleRegenerate,

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { Script, Knobs, Job, RenderJobResult, TaskStatus, VideoGenerationSettings } from "../types"; import { Script, Knobs, Job, RenderJobResult, TaskStatus, VideoGenerationSettings } from "../types";
import { podcastApi } from "../../../services/podcastApi"; import { podcastApi, getCachedVoiceCloneInfo } from "../../../services/podcastApi";
interface UseRenderQueueProps { interface UseRenderQueueProps {
script: Script; script: Script;
@@ -427,9 +427,14 @@ export const useRenderQueue = ({
}); });
try { try {
const cachedClone = getCachedVoiceCloneInfo();
const result: RenderJobResult = await podcastApi.renderSceneAudio({ const result: RenderJobResult = await podcastApi.renderSceneAudio({
scene, 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), emotion: scene.emotion || getSceneVoiceEmotion(knobs),
speed: knobs.voice_speed, speed: knobs.voice_speed,
}); });

View File

@@ -26,6 +26,9 @@ import { VoiceSelector } from "../../shared/VoiceSelector";
export type AudioGenerationSettings = { export type AudioGenerationSettings = {
voiceId: string; voiceId: string;
customVoiceId?: string; customVoiceId?: string;
useVoiceClone?: boolean;
voiceSampleUrl?: string;
voiceCloneEngine?: string;
speed: number; speed: number;
volume: number; volume: number;
pitch: number; pitch: number;

View File

@@ -16,7 +16,7 @@ import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { LineEditor } from "./LineEditor"; import { LineEditor } from "./LineEditor";
import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerateModal"; import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerateModal";
import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal"; import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal";
import { podcastApi } from "../../../services/podcastApi"; import { podcastApi, getCachedVoiceCloneInfo } from "../../../services/podcastApi";
import { aiApiClient } from "../../../api/client"; import { aiApiClient } from "../../../api/client";
import { getCachedMedia, setCachedMedia } from "../../../utils/mediaCache"; import { getCachedMedia, setCachedMedia } from "../../../utils/mediaCache";
@@ -68,6 +68,9 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({ const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
voiceId: knobs.voice_id || "Wise_Woman", voiceId: knobs.voice_id || "Wise_Woman",
customVoiceId: knobs.custom_voice_id || undefined, 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, speed: knobs.voice_speed ?? 1.0,
volume: 1.0, volume: 1.0,
pitch: 0.0, pitch: 0.0,
@@ -308,10 +311,14 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
// Generate audio // Generate audio
const effectiveSettings = settings || audioSettings; const effectiveSettings = settings || audioSettings;
const cachedClone = getCachedVoiceCloneInfo();
const result = await podcastApi.renderSceneAudio({ const result = await podcastApi.renderSceneAudio({
scene: currentScene, scene: currentScene,
voiceId: effectiveSettings.voiceId || knobs.voice_id || "Wise_Woman", 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", emotion: effectiveSettings.emotion || scene.emotion || knobs.voice_emotion || "neutral",
speed: effectiveSettings.speed ?? knobs.voice_speed ?? 1.0, speed: effectiveSettings.speed ?? knobs.voice_speed ?? 1.0,
volume: effectiveSettings.volume ?? 1.0, volume: effectiveSettings.volume ?? 1.0,

View File

@@ -7,8 +7,9 @@ import { podcastApi } from "../../../services/podcastApi";
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui"; import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
import { SceneEditor } from "./SceneEditor"; import { SceneEditor } from "./SceneEditor";
import { InlineAudioPlayer } from "../InlineAudioPlayer"; import { InlineAudioPlayer } from "../InlineAudioPlayer";
import { aiApiClient } from "../../../api/client"; import { aiApiClient, getApiUrl } from "../../../api/client";
import { BrollInfoPanel } from "./parts/BrollInfoPanel"; import { BrollInfoPanel } from "./parts/BrollInfoPanel";
import { ScriptEditorProvider } from "./ScriptEditorContext";
interface ScriptEditorProps { interface ScriptEditorProps {
projectId: string; projectId: string;
@@ -75,49 +76,8 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
} }
}, [initialScript]); }, [initialScript]);
useEffect(() => { // Note: Script generation is now handled by ScriptEditorProvider
// If script already exists, don't regenerate // to ensure BrollInfoPanel and other child components have access to context
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]);
const updateScene = (updated: Scene) => { const updateScene = (updated: Scene) => {
// Use functional update to ensure we're working with latest state // Use functional update to ensure we're working with latest state
@@ -309,14 +269,20 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
chart_type: scene.chart_data.type || "bar_comparison", chart_type: scene.chart_data.type || "bar_comparison",
title: scene.title, 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 { return {
...scene, ...scene,
broll_preview_url: result.preview_url, broll_preview_url: toFullUrl(result.preview_url),
chart_id: result.chart_id, chart_id: result.chart_id,
}; };
} catch (error) { } 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; return scene;
} }
}) })
@@ -379,11 +345,28 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
}, [script, emitScriptChange]); }, [script, emitScriptChange]);
return ( return (
<Box sx={{ mt: 4 }}> <ScriptEditorProvider
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}> projectId={projectId}
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}> idea={idea}
Back to Research rawResearch={rawResearch}
</SecondaryButton> knobs={knobs}
speakers={speakers}
durationMinutes={durationMinutes}
initialScript={script}
podcastMode={podcastMode}
analysis={analysis}
outline={outline}
onScriptChange={(s) => {
setScript(s);
onScriptChange(s);
}}
onError={onError}
>
<Box sx={{ mt: 4 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
Back to Research
</SecondaryButton>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography <Typography
variant="h4" variant="h4"
@@ -945,5 +928,6 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
</Stack> </Stack>
)} )}
</Box> </Box>
</ScriptEditorProvider>
); );
}; };

View File

@@ -320,6 +320,9 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
scenes: sceneData, scenes: sceneData,
voiceId: knobs.voice_id, voiceId: knobs.voice_id,
customVoiceId: knobs.custom_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, speed: knobs.voice_speed,
emotion: knobs.voice_emotion, emotion: knobs.voice_emotion,
englishNormalization: true, englishNormalization: true,

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { Stack, Box, Typography, Alert, Paper, Button, CircularProgress, Chip } from "@mui/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 } from "@mui/icons-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 { useScriptEditor } from "../ScriptEditorContext";
import { Script } from "../../types"; import { Script } from "../../types";
@@ -42,118 +42,245 @@ export const BrollInfoPanel: React.FC<BrollInfoPanelProps> = (props) => {
return ( return (
<Paper <Paper
sx={{ sx={{
p: 3, p: 2.5,
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.05) 0%, rgba(16, 185, 129, 0.05) 100%)", background: "linear-gradient(135deg, rgba(34, 197, 94, 0.03) 0%, rgba(16, 185, 129, 0.03) 100%)",
border: "1px solid rgba(34, 197, 94, 0.15)", border: "1px solid rgba(34, 197, 94, 0.15)",
borderRadius: 2, borderRadius: 2,
}} }}
> >
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 2 }}> <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Box sx={{ width: 40, height: 40, borderRadius: "50%", background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)", display: "flex", alignItems: "center", justifyContent: "center" }}> <Stack direction="row" alignItems="center" spacing={1.5}>
<BarChartIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} /> <Box sx={{
</Box> p: 0.75,
<Box sx={{ flex: 1 }}> borderRadius: 1.5,
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}> background: "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)",
B-Roll Charts display: "flex",
</Typography> alignItems: "center",
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}> justifyContent: "center"
Programmatic charts extracted from research data }}>
</Typography> <BarChartIcon sx={{ fontSize: 18, color: "#fff" }} />
</Box> </Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: "#0f172a", lineHeight: 1.2 }}>
B-Roll Charts
</Typography>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
{resolvedScenesWithCharts} chart{resolvedScenesWithCharts !== 1 ? 's' : ''} for visual storytelling
</Typography>
</Box>
</Stack>
{hasChartData && ( {hasChartData && (
<Chip <Button
label={`${resolvedScenesWithCharts} scene${resolvedScenesWithCharts > 1 ? 's' : ''} with charts`} variant="contained"
size="small" size="small"
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a", fontWeight: 600 }} startIcon={resolvedGeneratingChartId ? <CircularProgress size={14} color="inherit" /> : <AutoAwesomeIcon sx={{ fontSize: 16 }} />}
/> 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"}
</Button>
)} )}
</Stack> </Stack>
{!hasChartData ? ( {hasChartData ? (
<Alert severity="info" sx={{ background: "rgba(34, 197, 94, 0.06)", border: "1px solid rgba(34, 197, 94, 0.15)", "& .MuiAlert-icon": { color: "#22c55e" } }}> <Stack spacing={1.5}>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}> {scenesWithData.map((scene) => {
<strong style={{ fontWeight: 600 }}>No charts detected.</strong> If your research contains statistics or metrics, the script generation will automatically extract chart data for B-roll visualization. const chartData = scene.chart_data;
</Typography> const hasPreview = !!scene.broll_preview_url;
</Alert>
) : (
<Stack spacing={2}>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
Your script contains <strong style={{ fontWeight: 600 }}>{scenesWithData.length}</strong> scene(s) with chart data.
Click below to generate chart previews for the Write phase.
</Typography>
<Stack direction="row" spacing={2}> return (
<Button <Box
variant="contained" key={scene.id}
startIcon={resolvedGeneratingChartId ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />} sx={{
onClick={resolvedGenerateChartPreviews} p: 1.5,
disabled={!!resolvedGeneratingChartId || !resolvedGenerateChartPreviews} background: "#fff",
sx={{ borderRadius: 1.5,
background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)", border: "1px solid rgba(0,0,0,0.06)",
"&:hover": { background: "linear-gradient(135deg, #16a34a 0%, #059669 100%)" }, display: "flex",
textTransform: "none", alignItems: "center",
fontWeight: 600, gap: 2,
}} transition: "all 0.2s ease",
> "&:hover": {
{resolvedGeneratingChartId ? "Generating..." : "Generate Chart Previews"} borderColor: "rgba(34, 197, 94, 0.3)",
</Button> boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
</Stack> }
}}
{scenesWithData.map((scene) => ( >
<Box {/* Thumbnail */}
key={scene.id} <Box
sx={{ sx={{
p: 2, width: 72,
background: "rgba(0,0,0,0.02)", height: 48,
borderRadius: 1, flexShrink: 0,
display: "flex", borderRadius: 1,
alignItems: "center", overflow: "hidden",
justifyContent: "space-between" background: hasPreview ? "rgba(0,0,0,0.04)" : "linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%)",
}} display: "flex",
> alignItems: "center",
<Box> justifyContent: "center",
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> cursor: hasPreview ? "pointer" : "default",
{scene.title} transition: "all 0.2s ease",
</Typography> "&:hover": hasPreview ? {
<Typography variant="caption" sx={{ color: "#64748b" }}> transform: "scale(1.05)",
{scene.chart_data?.type || "chart"} {scene.chart_data?.labels?.length || 0} data points boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
</Typography> } : {}
</Box> }}
>
<Stack direction="row" spacing={1}> {resolvedGeneratingChartId === scene.id ? (
{resolvedGeneratingChartId === scene.id ? ( <CircularProgress size={24} sx={{ color: "#22c55e" }} />
<CircularProgress size={20} /> ) : hasPreview && scene.broll_preview_url ? (
) : scene.broll_preview_url ? ( <Box
<> component="img"
<Chip src={scene.broll_preview_url}
label="Preview Ready" alt={`Chart for ${scene.title}`}
size="small" sx={{
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a" }} width: "100%",
height: "100%",
objectFit: "cover",
}}
onClick={() => window.open(scene.broll_preview_url, '_blank')}
/> />
<Button ) : (
<BarChartIcon sx={{ fontSize: 20, color: "#94a3b8" }} />
)}
</Box>
{/* Chart Info */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{
fontWeight: 600,
color: "#1e293b",
fontSize: "0.8rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{scene.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.25 }}>
<Chip
label={chartData?.type || "chart"}
size="small"
sx={{
height: 18,
fontSize: "0.65rem",
background: "rgba(34, 197, 94, 0.1)",
color: "#16a34a",
fontWeight: 600,
}}
/>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
{chartData?.labels?.length || 0} labels
</Typography>
{hasPreview && (
<Chip
label="Ready"
size="small"
sx={{
height: 18,
fontSize: "0.65rem",
background: "rgba(34, 197, 94, 0.15)",
color: "#16a34a",
fontWeight: 600,
}}
/>
)}
</Stack>
</Box>
{/* Takeaway */}
{chartData?.takeaway && (
<Box sx={{
flex: 1.5,
display: { xs: "none", md: "block" },
px: 1,
py: 0.5,
background: "rgba(34, 197, 94, 0.04)",
borderRadius: 1,
}}>
<Typography variant="caption" sx={{
color: "#475569",
fontSize: "0.7rem",
fontStyle: "italic",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}>
"{chartData.takeaway}"
</Typography>
</Box>
)}
{/* Actions */}
<Stack direction="row" spacing={0.5}>
{hasPreview && (
<Tooltip title="View fullsize">
<IconButton
size="small"
onClick={() => scene.broll_preview_url && window.open(scene.broll_preview_url, '_blank')}
sx={{
color: "#64748b",
"&:hover": { color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }
}}
>
<FullscreenIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
)}
<Tooltip title="Regenerate">
<IconButton
size="small" size="small"
startIcon={<RefreshIcon />}
onClick={() => resolvedRegenerateChart?.(scene.id)} onClick={() => resolvedRegenerateChart?.(scene.id)}
disabled={!resolvedRegenerateChart} disabled={!resolvedRegenerateChart || !!resolvedGeneratingChartId}
sx={{
color: "#64748b",
"&:hover": { color: "#f59e0b", background: "rgba(245, 158, 11, 0.1)" }
}}
> >
Regenerate <RefreshIcon sx={{ fontSize: 18 }} />
</Button> </IconButton>
<Button </Tooltip>
<Tooltip title="Remove chart">
<IconButton
size="small" size="small"
startIcon={<DeleteIcon />}
onClick={() => resolvedRemoveChart?.(scene.id)} onClick={() => resolvedRemoveChart?.(scene.id)}
disabled={!resolvedRemoveChart} disabled={!resolvedRemoveChart}
sx={{ color: "#ef4444" }} sx={{
color: "#64748b",
"&:hover": { color: "#ef4444", background: "rgba(239, 68, 68, 0.1)" }
}}
> >
Remove <DeleteIcon sx={{ fontSize: 18 }} />
</Button> </IconButton>
</> </Tooltip>
) : null} </Stack>
</Stack> </Box>
</Box> );
))} })}
</Stack> </Stack>
) : (
<Box sx={{ py: 3, textAlign: "center" }}>
<BarChartIcon sx={{ fontSize: 36, color: "#cbd5e1", mb: 1 }} />
<Typography variant="body2" sx={{ color: "#64748b", fontSize: "0.8rem" }}>
No chart data yet. Add chart data to scenes to generate B-roll visuals.
</Typography>
</Box>
)} )}
</Paper> </Paper>
); );

View File

@@ -3,6 +3,9 @@ export type Knobs = {
voice_speed: number; voice_speed: number;
voice_id: string; voice_id: string;
custom_voice_id?: string; custom_voice_id?: string;
is_voice_clone?: boolean;
voice_sample_url?: string;
voice_clone_engine?: string;
resolution: string; resolution: string;
scene_length_target: number; scene_length_target: number;
sample_rate: number; sample_rate: number;

View File

@@ -5,12 +5,40 @@ interface GlassyCardProps {
children?: React.ReactNode; children?: React.ReactNode;
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
onClick?: () => void; 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<GlassyCardProps> = ({ children, sx, ...props }) => { export const GlassyCard: React.FC<GlassyCardProps> = ({ 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 ( return (
<Paper <Paper
className={className}
aria-label={ariaLabel}
sx={{ sx={{
borderRadius: 3, borderRadius: 3,
border: "1px solid rgba(15, 23, 42, 0.06)", border: "1px solid rgba(15, 23, 42, 0.06)",
@@ -25,7 +53,7 @@ export const GlassyCard: React.FC<GlassyCardProps> = ({ children, sx, ...props }
}, },
...sx ...sx
}} }}
{...props} {...filteredProps}
> >
{children} {children}
</Paper> </Paper>

View File

@@ -34,6 +34,10 @@ try {
export type AudioGenerationSettings = { export type AudioGenerationSettings = {
voiceId: string; voiceId: string;
customVoiceId?: string;
useVoiceClone?: boolean;
voiceSampleUrl?: string;
voiceCloneEngine?: string;
speed: number; speed: number;
volume: number; volume: number;
pitch: number; pitch: number;

View File

@@ -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" }, { 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<VoiceSelectorProps> = ({ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
value, value,

View File

@@ -60,6 +60,9 @@ export interface PodcastProjectState {
// Backend project creation status — prevents 404 sync calls before project exists // Backend project creation status — prevents 404 sync calls before project exists
backendProjectCreated?: boolean; backendProjectCreated?: boolean;
// Track last synced phase to prevent duplicate syncs
lastSyncedPhase?: string | null;
} }
const DEFAULT_KNOBS: Knobs = { const DEFAULT_KNOBS: Knobs = {
@@ -162,21 +165,28 @@ export const usePodcastProjectState = () => {
} }
}, [state]); }, [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(() => { useEffect(() => {
if (!state.project || !state.project.id || !state.backendProjectCreated) return; 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; const projectId = state.project.id;
// Clear existing timeout // Debounce - wait for state to settle before syncing
if (syncTimeoutRef.current) { if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current); clearTimeout(syncTimeoutRef.current);
} }
// Debounce database sync (wait 2 seconds after last change)
syncTimeoutRef.current = setTimeout(async () => { syncTimeoutRef.current = setTimeout(async () => {
try { try {
console.log(`[Sync] Saving project at phase: ${state.currentStep}`);
const dbState = { const dbState = {
analysis: state.analysis, analysis: state.analysis,
queries: state.queries, queries: state.queries,
@@ -195,39 +205,37 @@ export const usePodcastProjectState = () => {
status: state.currentStep === 'render' && state.renderJobs.every(j => j.status === 'completed') ? 'completed' : 'in_progress', 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) { } catch (error) {
console.error('Error syncing project to database:', error); console.error('[Sync] Error saving project:', error);
// Don't throw - localStorage is still working
} }
}, 2000); }, 1500);
return () => { return () => {
if (syncTimeoutRef.current) { if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current); clearTimeout(syncTimeoutRef.current);
} }
}; };
}, [ // Only sync when phase changes - not on every state field change
state.project, }, [state.currentStep, state.backendProjectCreated]);
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,
]);
// Setters // Setters
const setProject = useCallback((project: PodcastProjectState['project']) => { 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']) => { const setAnalysis = useCallback((analysis: PodcastProjectState['analysis']) => {
@@ -235,6 +243,7 @@ export const usePodcastProjectState = () => {
...prev, ...prev,
analysis, analysis,
currentStep: analysis ? 'research' : prev.currentStep, currentStep: analysis ? 'research' : prev.currentStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
})); }));
}, []); }, []);
@@ -255,6 +264,7 @@ export const usePodcastProjectState = () => {
...prev, ...prev,
research, research,
currentStep: research ? 'script' : prev.currentStep, currentStep: research ? 'script' : prev.currentStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
})); }));
}, []); }, []);
@@ -272,6 +282,7 @@ export const usePodcastProjectState = () => {
...prev, ...prev,
scriptData, scriptData,
currentStep: scriptData ? 'render' : prev.currentStep, currentStep: scriptData ? 'render' : prev.currentStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
})); }));
}, []); }, []);

View File

@@ -93,9 +93,14 @@ billingAPI.interceptors.response.use(
async (error) => { async (error) => {
const originalRequest = error.config; const originalRequest = error.config;
// Handle network errors // Handle network errors - but NOT timeouts (backend might just be slow)
if (!error.response) { 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); console.error('Billing API Network Error:', error.message);
return Promise.reject(error); return Promise.reject(error);
} }

View File

@@ -1,3 +1,4 @@
import { noteBackendRecovered } from "../api/client";
import { ResearchProvider, ResearchConfig } from "./blogWriterApi"; import { ResearchProvider, ResearchConfig } from "./blogWriterApi";
import { import {
storyWriterApi, storyWriterApi,
@@ -28,12 +29,42 @@ const DEFAULT_KNOBS: Knobs = {
voice_speed: 1, voice_speed: 1,
voice_id: "Wise_Woman", voice_id: "Wise_Woman",
custom_voice_id: undefined, custom_voice_id: undefined,
is_voice_clone: undefined,
voice_sample_url: undefined,
voice_clone_engine: undefined,
resolution: "720p", resolution: "720p",
scene_length_target: 45, scene_length_target: 45,
sample_rate: 24000, sample_rate: 24000,
bitrate: "standard", 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 sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const createId = (prefix: string) => { const createId = (prefix: string) => {
@@ -244,9 +275,9 @@ const mapExaResearchResponse = (response: any): Research => {
}; };
const ensurePreflight = async (operation: PreflightOperation) => { 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); 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) { if (!result.can_proceed) {
const message = result.operations[0]?.message || "Pre-flight validation failed"; const message = result.operations[0]?.message || "Pre-flight validation failed";
throw new Error(message); throw new Error(message);
@@ -379,7 +410,9 @@ export const podcastApi = {
bible: params.bible, bible: params.bible,
analysis: params.analysis, analysis: params.analysis,
}, { timeout: 300000 }); // 5 minute timeout for research }, { 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) { } catch (error: any) {
console.error('[podcastApi] Exa research error:', error?.response?.status, error?.response?.data, error?.message); console.error('[podcastApi] Exa research error:', error?.response?.status, error?.response?.data, error?.message);
throw error; throw error;
@@ -497,6 +530,9 @@ export const podcastApi = {
scene: Scene; scene: Scene;
voiceId?: string; voiceId?: string;
customVoiceId?: string; customVoiceId?: string;
useVoiceClone?: boolean;
voiceSampleUrl?: string;
voiceCloneEngine?: string;
emotion?: string; // Fallback if scene doesn't have emotion emotion?: string; // Fallback if scene doesn't have emotion
speed?: number; speed?: number;
volume?: number; volume?: number;
@@ -600,7 +636,7 @@ export const podcastApi = {
channel: params.channel || null, channel: params.channel || null,
format: params.format || null, format: params.format || null,
language_boost: params.languageBoost || null, language_boost: params.languageBoost || null,
}); }, { timeout: 300000 }); // 5 minute timeout for voice clone / TTS
return { return {
audioUrl: response.data.audio_url, audioUrl: response.data.audio_url,
@@ -623,12 +659,14 @@ export const podcastApi = {
}, },
// Project persistence endpoints // Project persistence endpoints
async saveProject(projectId: string, state: any): Promise<void> { async saveProject(projectId: string, state: any): Promise<boolean> {
try { try {
await aiApiClient.put(`/api/podcast/projects/${projectId}`, state); await aiApiClient.put(`/api/podcast/projects/${projectId}`, state);
return true;
} catch (error) { } catch (error) {
console.error("Failed to save project to database:", 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 }[] }[]; scenes: { id: string; title: string; lines: { text: string }[] }[];
voiceId: string; voiceId: string;
customVoiceId?: string; customVoiceId?: string;
useVoiceClone?: boolean;
voiceSampleUrl?: string;
voiceCloneEngine?: string;
speed: number; speed: number;
emotion: string; emotion: string;
englishNormalization?: boolean; englishNormalization?: boolean;