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
# Directory paths
# router.py is at: backend/api/podcast/router.py
# parents[0] = backend/api/podcast/
# parents[1] = backend/api/
# parents[2] = backend/
# parents[3] = root/
ROOT_DIR = Path(__file__).resolve().parents[3] # root/
DATA_MEDIA_DIR = ROOT_DIR / "data" / "media"
# Find root by looking for 'data' or 'backend' folder
def _find_root() -> Path:
"""Find project root by searching up for data directory."""
current = Path(__file__).resolve()
for _ in range(10): # max 10 levels up
if (current / "data").exists() and (current / "data" / "media").exists():
return current
if (current / "backend").exists():
return current / "backend"
parent = current.parent
if parent == current:
break
current = parent
# Fallback: assume backend is root
return Path(__file__).resolve().parents[1]
PODCAST_AUDIO_DIR = (DATA_MEDIA_DIR / "podcast_audio").resolve()
PODCAST_IMAGES_DIR = (DATA_MEDIA_DIR / "podcast_images").resolve()
PODCAST_VIDEOS_DIR = (DATA_MEDIA_DIR / "podcast_videos").resolve()
ROOT_DIR = _find_root()
# Video subdirectory
# Video subdirectory (relative to workspace media dir)
AI_VIDEO_SUBDIR = Path("AI_Videos")
MediaType = Literal["audio", "image", "video"]
# Legacy constants - DEPRECATED, use get_podcast_media_dir() instead
# Kept for backward compatibility with some handlers
PODCAST_AVATARS_SUBDIR = Path("avatars")
MediaType = Literal["audio", "image", "video", "chart"]
def _sanitize_user_id(user_id: str) -> str:
@@ -38,21 +48,31 @@ def get_podcast_media_dir(
*,
ensure_exists: bool = False,
) -> Path:
"""Resolve podcast media directory (tenant workspace first, legacy global fallback)."""
"""
Resolve podcast media directory (workspace-only for multi-tenant isolation).
Always requires user_id for tenant isolation. Falls back to default workspace
only if no user_id provided (for backward compat in development).
"""
media_subdir = {
"audio": "podcast_audio",
"image": "podcast_images",
"video": "podcast_videos",
"chart": "podcast_charts",
}[media_type]
if user_id:
sanitized = _sanitize_user_id(user_id)
tenant_media_dir = ROOT_DIR / "workspace" / f"workspace_{sanitized}" / "media" / media_subdir
resolved_dir = tenant_media_dir.resolve()
resolved_dir = (
ROOT_DIR / "workspace" / f"workspace_{sanitized}" / "media" / media_subdir
).resolve()
else:
resolved_dir = (DATA_MEDIA_DIR / media_subdir).resolve()
# Development fallback: use a default workspace
resolved_dir = (
ROOT_DIR / "workspace" / "workspace_alwrity" / "media" / media_subdir
).resolve()
logger.debug(f"[Podcast] get_podcast_media_dir: type={media_type}, user_id={user_id}, sanitized={user_id and _sanitize_user_id(user_id)}, resolved={resolved_dir}")
logger.warning(f"[Podcast] get_podcast_media_dir: type={media_type}, user_id={user_id}, resolved={resolved_dir}")
if ensure_exists:
resolved_dir.mkdir(parents=True, exist_ok=True)
@@ -61,14 +81,11 @@ def get_podcast_media_dir(
def get_podcast_media_read_dirs(media_type: MediaType, user_id: str | None = None) -> list[Path]:
"""Return ordered directories to search (tenant path first, then legacy global path)."""
dirs: list[Path] = []
if user_id:
dirs.append(get_podcast_media_dir(media_type, user_id))
logger.debug(f"[Podcast] get_podcast_media_read_dirs: added user dir for {user_id}")
dirs.append(get_podcast_media_dir(media_type, None))
logger.debug(f"[Podcast] get_podcast_media_read_dirs: dirs={dirs}")
return dirs
"""
Return directories to search for podcast media.
Now workspace-only (no legacy fallback).
"""
return [get_podcast_media_dir(media_type, user_id)]
def get_podcast_audio_service(user_id: str | None = None) -> StoryAudioGenerationService:

View File

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

View File

@@ -12,7 +12,15 @@ from pathlib import Path
from urllib.parse import urlparse
import tempfile
import uuid
import hashlib
import time
import shutil
import requests
import asyncio
from concurrent.futures import ThreadPoolExecutor
import asyncio
from concurrent.futures import ThreadPoolExecutor
from services.database import get_db
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
@@ -31,6 +39,124 @@ from ..models import (
router = APIRouter()
# Thread pool for CPU/IO-intensive voice clone operations
_audio_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="podcast_audio")
# In-memory LRU cache for voice samples (per user) to avoid re-downloading
_voice_sample_cache: dict[str, tuple[float, bytes]] = {}
_VOICE_SAMPLE_CACHE_TTL = 1800 # 30 minutes
def _get_cached_voice_sample(cache_key: str) -> Optional[bytes]:
"""Get voice sample bytes from in-memory cache if fresh."""
if cache_key in _voice_sample_cache:
ts, data = _voice_sample_cache[cache_key]
if time.time() - ts < _VOICE_SAMPLE_CACHE_TTL:
logger.debug(f"[Podcast] Voice sample cache hit for {cache_key[:16]}...")
return data
del _voice_sample_cache[cache_key]
return None
def _cache_voice_sample(cache_key: str, data: bytes) -> None:
"""Store voice sample bytes in in-memory cache."""
# Evict oldest entries if cache grows too large
if len(_voice_sample_cache) > 50:
oldest_key = min(_voice_sample_cache, key=lambda k: _voice_sample_cache[k][0])
del _voice_sample_cache[oldest_key]
_voice_sample_cache[cache_key] = (time.time(), data)
def _get_latest_voice_sample_url(user_id: str, db) -> Optional[str]:
"""Get the latest voice sample URL for a user from their voice clone assets."""
try:
from models.content_asset_models import ContentAsset, AssetType, AssetSource
from sqlalchemy import desc
asset = db.query(ContentAsset).filter(
ContentAsset.user_id == user_id,
ContentAsset.asset_type == AssetType.AUDIO,
ContentAsset.source_module == AssetSource.VOICE_CLONER,
).order_by(desc(ContentAsset.created_at)).first()
if asset and asset.file_url:
logger.info(f"[Podcast] Found voice sample for user {user_id}: {asset.file_url}")
return asset.file_url
logger.warning(f"[Podcast] No voice sample asset found for user {user_id}")
return None
except Exception as e:
logger.error(f"[Podcast] Error fetching voice sample URL: {e}")
return None
def _fetch_voice_sample(voice_sample_url: str, user_id: str) -> Optional[bytes]:
"""Fetch voice sample audio bytes from URL, with caching."""
cache_key = hashlib.md5(f"{user_id}:{voice_sample_url}".encode()).hexdigest()
# Check in-memory cache first
cached = _get_cached_voice_sample(cache_key)
if cached is not None:
return cached
try:
from utils.media_utils import resolve_media_path
# Try resolving as a local workspace path first (fastest)
if "/api/assets/" in voice_sample_url:
# Resolve user workspace path directly
sanitized_uid = "".join(c for c in user_id if c.isalnum() or c in ("-", "_"))
from api.podcast.constants import ROOT_DIR
parts = voice_sample_url.split("/")
# Expected: /api/assets/{user_id}/voice_samples/{filename}
try:
idx = parts.index("voice_samples")
filename = parts[idx + 1].split("?")[0]
local_path = ROOT_DIR / "workspace" / f"workspace_{sanitized_uid}" / "assets" / "voice_samples" / filename
if local_path.exists():
data = local_path.read_bytes()
_cache_voice_sample(cache_key, data)
logger.info(f"[Podcast] Voice sample loaded from workspace: {local_path}")
return data
except (ValueError, IndexError):
pass
# Fall back to media utils resolver
local_path = resolve_media_path(voice_sample_url)
if local_path and local_path.exists():
data = local_path.read_bytes()
_cache_voice_sample(cache_key, data)
return data
# Try resolving as a podcast audio file
if "/api/podcast/audio/" in voice_sample_url:
filename = voice_sample_url.split("/api/podcast/audio/")[-1].split("?")[0]
try:
audio_dir = get_podcast_media_dir("audio", user_id)
local_path = audio_dir / filename
if local_path.exists():
data = local_path.read_bytes()
_cache_voice_sample(cache_key, data)
return data
except Exception:
pass
# Try direct HTTP fetch as fallback
if voice_sample_url.startswith("http"):
logger.info(f"[Podcast] Fetching voice sample via HTTP: {voice_sample_url[:80]}...")
resp = requests.get(voice_sample_url, timeout=30)
if resp.status_code == 200:
data = resp.content
_cache_voice_sample(cache_key, data)
logger.info(f"[Podcast] Voice sample fetched via HTTP ({len(data)} bytes)")
return data
logger.warning(f"[Podcast] Could not fetch voice sample from: {voice_sample_url}")
return None
except Exception as e:
logger.error(f"[Podcast] Error fetching voice sample: {e}")
return None
@router.post("/audio/upload")
async def upload_podcast_audio(
@@ -125,35 +251,176 @@ async def generate_podcast_audio(
raise HTTPException(status_code=400, detail="Text is required")
try:
audio_service = get_podcast_audio_service(user_id)
logger.warning(f"[Podcast] Generating audio with service dir: {audio_service.output_dir}")
result: StoryAudioResult = audio_service.generate_ai_audio(
scene_number=0,
scene_title=request.scene_title,
text=request.text.strip(),
user_id=user_id,
voice_id=request.voice_id or "Wise_Woman",
custom_voice_id=request.custom_voice_id,
speed=request.speed or 1.0, # Normal speed (was 0.9, but too slow - causing duration issues)
volume=request.volume or 1.0,
pitch=request.pitch or 0.0, # Normal pitch (0.0 = neutral)
emotion=request.emotion or "neutral",
english_normalization=request.english_normalization or False,
sample_rate=request.sample_rate,
bitrate=request.bitrate,
channel=request.channel,
format=request.format,
language_boost=request.language_boost,
enable_sync_mode=request.enable_sync_mode,
# Determine if we should use voice clone path
# Voice clone is used when: explicitly requested, OR when voice_id/custom_voice_id indicates a clone
# (cloned voice IDs start with "vc_" or match the placeholder "MY_VOICE_CLONE")
_vid = request.voice_id or ""
_cvid = request.custom_voice_id or ""
is_voice_clone = request.use_voice_clone or (
_cvid.startswith("vc_") or _cvid == "MY_VOICE_CLONE"
) or (
_vid.startswith("vc_") or _vid == "MY_VOICE_CLONE"
)
# Override URL to use podcast endpoint instead of story endpoint
if result.get("audio_url") and "/api/story/audio/" in result.get("audio_url", ""):
audio_filename = result.get("audio_filename", "")
result["audio_url"] = f"/api/podcast/audio/{audio_filename}"
logger.warning(f"[Podcast] Audio generated - path: {result.get('audio_path')}, url: {result.get('audio_url')}")
# If voice_id is a clone ID, normalize it to use Wise_Woman for TTS fallback
effective_voice_id = _vid if not (_vid.startswith("vc_") or _vid == "MY_VOICE_CLONE") else "Wise_Woman"
logger.warning(f"[Podcast] Audio request: use_voice_clone={request.use_voice_clone}, voice_id={request.voice_id}, custom_voice_id={request.custom_voice_id}, is_voice_clone={is_voice_clone}, voice_sample_url={request.voice_sample_url}, voice_clone_engine={request.voice_clone_engine}")
# Voice clone path: use user's voice sample with scene text as reference
if is_voice_clone:
# If no voice_sample_url provided, try to fetch it from the user's latest voice clone
voice_sample_url = request.voice_sample_url
if not voice_sample_url:
try:
voice_sample_url = _get_latest_voice_sample_url(user_id, db)
logger.warning(f"[Podcast] DB fallback voice sample URL for user {user_id}: {voice_sample_url}")
except Exception as e:
logger.warning(f"[Podcast] Could not fetch voice sample URL: {e}")
if voice_sample_url:
from services.llm_providers.main_audio_generation import qwen3_voice_clone, cosyvoice_voice_clone
engine = (request.voice_clone_engine or "qwen3").lower()
logger.warning(f"[Podcast] 🔊 Voice clone path: engine={engine}, scene='{request.scene_title}', voice_sample_url={voice_sample_url[:80]}...")
# Download voice sample from URL (with caching)
logger.warning(f"[Podcast] Fetching voice sample from: {voice_sample_url}")
try:
voice_sample_bytes = _fetch_voice_sample(voice_sample_url, user_id)
except Exception as fetch_err:
logger.error(f"[Podcast] ❌ Failed to fetch voice sample: {fetch_err}", exc_info=True)
raise HTTPException(status_code=400, detail=f"Could not fetch voice sample: {str(fetch_err)}")
logger.warning(f"[Podcast] Voice sample fetch result: {len(voice_sample_bytes) if voice_sample_bytes else 0} bytes")
if not voice_sample_bytes:
raise HTTPException(status_code=400, detail=f"Could not fetch voice sample from {voice_sample_url}")
scene_text = request.text.strip()
if len(scene_text) > 4000:
scene_text = scene_text[:4000]
# Run voice clone in thread pool to avoid blocking the event loop
loop = asyncio.get_event_loop()
try:
if engine == "minimax":
from services.llm_providers.main_audio_generation import clone_voice
import random
import string
random_suffix = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
custom_vid = request.custom_voice_id or f"vc_{random_suffix}"
result_obj = await loop.run_in_executor(
_audio_executor,
lambda cv=custom_vid: clone_voice(
audio_bytes=voice_sample_bytes,
custom_voice_id=cv,
text=scene_text,
user_id=user_id,
),
)
audio_bytes = result_obj.preview_audio_bytes
provider = "minimax"
model = "minimax/voice-clone"
elif engine == "cosyvoice":
result_obj = await loop.run_in_executor(
_audio_executor,
lambda: cosyvoice_voice_clone(
audio_bytes=voice_sample_bytes,
text=scene_text,
user_id=user_id,
),
)
audio_bytes = result_obj.preview_audio_bytes
provider = "wavespeed-ai"
model = "wavespeed-ai/cosyvoice-tts/voice-clone"
else:
result_obj = await loop.run_in_executor(
_audio_executor,
lambda: qwen3_voice_clone(
audio_bytes=voice_sample_bytes,
text=scene_text,
user_id=user_id,
),
)
audio_bytes = result_obj.preview_audio_bytes
provider = "wavespeed-ai"
model = "wavespeed-ai/qwen3-tts/voice-clone"
logger.warning(f"[Podcast] 🔊 Voice clone result: {len(audio_bytes) if audio_bytes else 0} bytes, provider={provider}")
except HTTPException:
raise
except Exception as clone_err:
logger.error(f"[Podcast] ❌ Voice clone failed: {clone_err}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Voice clone generation failed: {str(clone_err)}")
# Save audio bytes to file
audio_service = get_podcast_audio_service(user_id)
audio_filename = f"scene_{request.scene_id}_{uuid.uuid4().hex[:8]}.mp3"
audio_path = audio_service.output_dir / audio_filename
with open(audio_path, "wb") as f:
f.write(audio_bytes)
file_size = len(audio_bytes)
audio_url = f"/api/podcast/audio/{audio_filename}"
cost = max(0.005, 0.005 * (len(scene_text) / 100.0))
result = {
"audio_path": str(audio_path),
"audio_filename": audio_filename,
"audio_url": audio_url,
"file_size": file_size,
"provider": provider,
"model": model,
"cost": cost,
"scene_number": 0,
"scene_title": request.scene_title,
}
else:
# Standard TTS path - but NOT if custom_voice_id is a clone ID
# Clone IDs (vc_*, MY_VOICE_CLONE) are not valid for minimax TTS
if is_voice_clone:
logger.warning(f"[Podcast] ⚠️ Voice clone detected but no voice sample available - falling back to standard TTS with voice_id={effective_voice_id}")
effective_custom_voice_id = request.custom_voice_id
if effective_custom_voice_id and (
effective_custom_voice_id.startswith("vc_") or
effective_custom_voice_id == "MY_VOICE_CLONE"
):
logger.warning(f"[Podcast] Ignoring clone ID '{effective_custom_voice_id}' in standard TTS path - no voice sample URL available")
effective_custom_voice_id = None
audio_service = get_podcast_audio_service(user_id)
logger.warning(f"[Podcast] Standard TTS path: voice_id={effective_voice_id}, custom_voice_id={effective_custom_voice_id}")
result: StoryAudioResult = audio_service.generate_ai_audio(
scene_number=0,
scene_title=request.scene_title,
text=request.text.strip(),
user_id=user_id,
voice_id=effective_voice_id,
custom_voice_id=effective_custom_voice_id,
speed=request.speed or 1.0, # Normal speed (was 0.9, but too slow - causing duration issues)
volume=request.volume or 1.0,
pitch=request.pitch or 0.0, # Normal pitch (0.0 = neutral)
emotion=request.emotion or "neutral",
english_normalization=request.english_normalization or False,
sample_rate=request.sample_rate,
bitrate=request.bitrate,
channel=request.channel,
format=request.format,
language_boost=request.language_boost,
enable_sync_mode=request.enable_sync_mode,
)
# Override URL to use podcast endpoint instead of story endpoint
if result.get("audio_url") and "/api/story/audio/" in result.get("audio_url", ""):
audio_filename = result.get("audio_filename", "")
result["audio_url"] = f"/api/podcast/audio/{audio_filename}"
logger.warning(f"[Podcast] Audio generated - path: {result.get('audio_path')}, url: {result.get('audio_url')}")
except Exception as exc:
logger.error(f"[Podcast] ❌ Audio generation failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Audio generation failed: {exc}")
# Save to asset library (podcast module)

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

View File

@@ -191,8 +191,11 @@ async def generate_chart_preview(
"""
user_id = require_authenticated_user(current_user)
# Debug logging
logger.warning(f"[Broll] Chart preview request: type={request.chart_type}, title={request.title}, chart_data keys={list(request.chart_data.keys())}, user_id={user_id}")
try:
broll_service = get_broll_service()
broll_service = get_broll_service(user_id=user_id)
chart_id = uuid.uuid4().hex[:8]
preview_path = broll_service.generate_chart_preview(
@@ -203,11 +206,17 @@ async def generate_chart_preview(
chart_id=chart_id,
)
# If chart generation failed (empty path), return a placeholder instead of 500
if not preview_path:
raise HTTPException(status_code=500, detail="Failed to generate chart preview")
# Return a fallback response so frontend doesn't crash
logger.warning(f"[Broll] Chart preview skipped - invalid data for type: {request.chart_type}")
return ChartPreviewResponse(
preview_url="",
chart_id=chart_id,
)
preview_filename = Path(preview_path).name
preview_url = f"/api/podcast/broll/preview/{chart_id}/{preview_filename}"
preview_url = f"/api/podcast/preview/{chart_id}/{preview_filename}"
return ChartPreviewResponse(
preview_url=preview_url,
@@ -324,17 +333,29 @@ async def compose_broll_videos(
async def serve_chart_preview(
chart_id: str,
filename: str,
current_user: Dict[str, Any] = Depends(get_current_user),
user_id: Optional[str] = None,
):
"""Serve chart preview PNG files."""
user_id = require_authenticated_user(current_user)
"""
Serve chart preview PNG files.
broll_service = get_broll_service()
- user_id passed as query param for multi-tenant workspace resolution
- endpoint is public (no auth) to allow direct image loading in browser
"""
# Validate filename to prevent directory traversal
if ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(status_code=400, detail="Invalid filename")
logger.warning(f"[Broll] serve_chart_preview: chart_id={chart_id}, filename={filename}, user_id={user_id}")
broll_service = get_broll_service(user_id=user_id)
expected_filename = broll_service.get_chart_preview_filename(chart_id)
if filename != expected_filename:
raise HTTPException(status_code=404, detail="Chart preview not found")
file_path = broll_service.get_output_path(filename)
# Use expected_filename to get the correct path
file_path = broll_service.get_output_path(expected_filename)
logger.warning(f"[Broll] serve_chart_preview: resolved path={file_path}, exists={file_path.exists()}")
if not file_path.exists():
raise HTTPException(status_code=404, detail="Chart preview not found")
@@ -342,7 +363,7 @@ async def serve_chart_preview(
return FileResponse(
path=str(file_path),
media_type="image/png",
filename=filename,
filename=expected_filename,
)

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

View File

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

View File

@@ -9,6 +9,7 @@ from typing import Dict, Any, List
from types import SimpleNamespace
import json
import re
import time
from datetime import datetime, timezone
from sqlalchemy.orm import Session
@@ -138,10 +139,12 @@ async def podcast_research_exa(
Run podcast research via Exa and then use LLM to extract deep insights.
Uses Podcast Bible and Analysis context for hyper-personalization.
"""
start_time = time.time()
user_id = require_authenticated_user(current_user)
logger.warning(f"[Podcast Research] ========== REQUEST START ==========")
logger.warning(f"[Podcast Research] User: {user_id}, Topic: {request.topic[:80]}...")
logger.warning(f"[Podcast Research] Queries count: {len(request.queries) if request.queries else 0}")
# Log only essential info, not full request data
logger.warning(f"[Podcast Research] ===== RESEARCH_START =====")
logger.warning(f"[Podcast Research] user={user_id}, topic='{request.topic[:50]}...', queries={len(request.queries) if request.queries else 0}")
queries = [q.strip() for q in request.queries if q and q.strip()]
@@ -424,6 +427,10 @@ QUALITY STANDARDS:
include_avatar_phase=True,
)
duration_ms = int((time.time() - start_time) * 1000)
logger.warning(f"[Podcast Research] ===== RESEARCH_END (took {duration_ms}ms) =====")
logger.warning(f"[Podcast Research] sources={len(sources_payload)}, insights={len(key_insights)}, summary_len={len(summary)}")
return PodcastExaResearchResponse(
sources=sources_payload,
search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries,

View File

@@ -9,6 +9,7 @@ from typing import Dict, Any, Optional
from pydantic import BaseModel, Field
import json
import re
import time
from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_user
@@ -60,11 +61,11 @@ async def generate_podcast_script(
Generate a podcast script outline (scenes + lines) using podcast-oriented prompting.
"""
user_id = require_authenticated_user(current_user)
logger.warning(f"[ScriptGen] ========== SCRIPT GENERATION START ==========")
logger.warning(f"[ScriptGen] Topic: {request.idea[:60]}...")
logger.warning(f"[ScriptGen] Duration: {request.duration_minutes} min, Speakers: {request.speakers}")
start_time = time.time()
logger.warning(f"[ScriptGen] ===== SCRIPT_GEN_START =====")
logger.warning(f"[ScriptGen] user={user_id}, topic='{request.idea[:50]}...', duration={request.duration_minutes}min, speakers={request.speakers}")
podcast_mode = (request.podcast_mode or "video_only").strip().lower()
logger.warning(f"[ScriptGen] Has research: {bool(request.research)}, Has bible: {bool(request.bible)}, Has analysis: {bool(request.analysis)}, Mode: {podcast_mode}")
logger.warning(f"[ScriptGen] research={bool(request.research)}, bible={bool(request.bible)}, analysis={bool(request.analysis)}, mode={podcast_mode}")
research_fact_cards = request.research.get("factCards", []) if request.research else []
# Build comprehensive research context for higher-quality scripts
@@ -399,5 +400,8 @@ COST OPTIMIZATION:
logger.warning(f"[ScriptGen] Script generated: {len(scenes)} scenes, {total_lines_output}/{total_lines_input} lines")
if dropped_empty_lines > 0:
logger.warning(f"[ScriptGen] Dropped {dropped_empty_lines} empty lines")
duration_ms = int((time.time() - start_time) * 1000)
logger.warning(f"[ScriptGen] ===== SCRIPT_GEN_END (took {duration_ms}ms) =====")
return PodcastScriptResponse(scenes=scenes)

View File

@@ -223,6 +223,9 @@ class PodcastAudioRequest(BaseModel):
text: str
voice_id: Optional[str] = "Wise_Woman"
custom_voice_id: Optional[str] = None # Voice clone ID for custom voice
use_voice_clone: Optional[bool] = False # If True, use voice clone with voice_sample_url
voice_sample_url: Optional[str] = None # URL to user's voice sample for cloning
voice_clone_engine: Optional[str] = None # Engine: "qwen3", "minimax", "cosyvoice"
speed: Optional[float] = 1.0
volume: Optional[float] = 1.0
pitch: Optional[float] = 0.0

View File

@@ -12,7 +12,7 @@ import uuid
import os
import tempfile
from pathlib import Path
from typing import Dict, Any, Optional, List
from typing import Dict, Any, Optional, List, TYPE_CHECKING
from loguru import logger
# Import chart generators directly
@@ -34,21 +34,27 @@ from services.podcast.broll_composer import (
class BrollService:
"""Orchestrates B-roll composition for podcast scenes."""
def __init__(self, output_dir: Optional[str] = None):
def __init__(self, output_dir: Optional[str] = None, user_id: Optional[str] = None):
"""
Initialize B-roll service.
Args:
output_dir: Base directory for B-roll output. Defaults to temp directory.
output_dir: Base directory for B-roll output. Defaults to workspace chart directory.
user_id: User ID for multi-tenant workspace isolation.
"""
if output_dir:
self.output_dir = Path(output_dir)
else:
self.output_dir = Path(tempfile.gettempdir()) / "broll_output"
self.output_dir = self._get_chart_dir(user_id)
self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[BrollService] Initialized with output directory: {self.output_dir}")
def _get_chart_dir(self, user_id: Optional[str] = None) -> Path:
"""Get chart directory from podcast constants (workspace-aware)."""
from api.podcast.constants import get_podcast_media_dir
return get_podcast_media_dir("chart", user_id, ensure_exists=True)
def get_output_path(self, filename: str) -> Path:
"""Get output path for a file."""
return self.output_dir / filename
@@ -84,29 +90,91 @@ class BrollService:
resolved_chart_id = chart_id or uuid.uuid4().hex[:8]
out_path = str(self.get_chart_preview_path(resolved_chart_id))
# Debug logging
logger.warning(f"[BrollService] Generating: type={chart_type}, data keys={list(chart_data.keys())}")
try:
if chart_type == "bar_comparison":
make_bar_chart(chart_data, out_path, title, subtitle=subtitle)
# Accept both formats: {labels, before, after} OR {labels, values}
labels = chart_data.get("labels", [])
before = chart_data.get("before", [])
after = chart_data.get("after", [])
# If using new format (labels, values), treat as single bar chart
if not before and not after:
values = chart_data.get("values", [])
if values:
# Use original labels, set before to zeros, values go to after
before = [0] * len(labels)
after = values[:len(labels)]
# Create modified data dict with proper format for make_bar_chart
chart_data_for_render = {
"labels": labels,
"before": before,
"after": after
}
else:
chart_data_for_render = chart_data
else:
chart_data_for_render = chart_data
if not labels or (not before and not after):
logger.warning(f"[BrollService] Missing required data for bar_comparison: labels={len(labels)}, before={len(before)}, after={len(after)}")
return ""
if len(labels) != len(before) or len(labels) != len(after):
logger.warning(f"[BrollService] Data shape mismatch: labels={len(labels)}, before={len(before)}, after={len(after)}")
return ""
make_bar_chart(chart_data_for_render, out_path, title, subtitle=subtitle)
elif chart_type == "bar_horizontal":
labels = chart_data.get("labels", [])
values = chart_data.get("values", [])
if not labels or not values:
logger.warning("[BrollService] Missing required data for bar_horizontal")
return ""
make_horizontal_bar(chart_data, out_path, title)
elif chart_type == "line_trend":
labels = chart_data.get("labels", [])
values = chart_data.get("values", [])
if not labels or not values:
logger.warning("[BrollService] Missing required data for line_trend")
return ""
make_line_trend(chart_data, out_path, title)
elif chart_type == "pie":
make_pie_chart(chart_data, out_path, title)
elif chart_type == "pie":
labels = chart_data.get("labels", [])
values = chart_data.get("values", [])
if not labels or not values:
logger.warning("[BrollService] Missing required data for pie")
return ""
make_pie_chart(chart_data, out_path, title)
elif chart_type == "stacked_bar":
labels = chart_data.get("labels", [])
segments = chart_data.get("segments", [])
if not labels or not segments:
logger.warning("[BrollService] Missing required data for stacked_bar")
return ""
make_stacked_bar(chart_data, out_path, title)
elif chart_type == "bullet":
elif chart_type == "bullet" or chart_type == "bullet_points":
# Accept both: bullet_points OR labels
bullet_points = chart_data.get("bullet_points", [])
# If using new format, use labels as bullet points
if not bullet_points:
bullet_points = chart_data.get("labels", [])
if not bullet_points:
labels_fallback = chart_data.get("labels", [])
if labels_fallback:
bullet_points = labels_fallback
if bullet_points:
make_bullet_overlay(bullet_points, out_path)
else:
logger.warning("[BrollService] No bullet points provided")
return ""
else:
logger.warning(f"[BrollService] Unknown chart type: {chart_type}")
return ""
logger.warning(f"[BrollService] Unknown chart type: {chart_type}, falling back to bar_comparison")
# Try bar_comparison as fallback
try:
make_bar_chart(chart_data, out_path, title, subtitle=subtitle)
return out_path
except Exception as fallback_err:
logger.warning(f"[BrollService] Fallback also failed: {fallback_err}")
return ""
logger.info(f"[BrollService] Chart preview generated: {out_path}")
return out_path
@@ -254,13 +322,21 @@ class BrollService:
logger.warning(f"[BrollService] Failed to remove {file}: {e}")
# Singleton instance for reuse
_broll_service_instance: Optional[BrollService] = None
# Per-user service instances for multi-tenant isolation
_broll_service_instances: Dict[str, BrollService] = {}
def get_broll_service(output_dir: Optional[str] = None) -> BrollService:
"""Get or create B-roll service singleton."""
global _broll_service_instance
if _broll_service_instance is None:
_broll_service_instance = BrollService(output_dir=output_dir)
return _broll_service_instance
def get_broll_service(output_dir: Optional[str] = None, user_id: Optional[str] = None) -> BrollService:
"""
Get or create B-roll service for the given user.
For multi-tenant isolation, pass user_id to get user-specific directory.
"""
if output_dir:
return BrollService(output_dir=output_dir)
# Create per-user instance based on user_id
cache_key = user_id or "default"
if cache_key not in _broll_service_instances:
_broll_service_instances[cache_key] = BrollService(user_id=user_id)
return _broll_service_instances[cache_key]

View File

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

View File

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

View File

@@ -461,9 +461,11 @@ aiApiClient.interceptors.response.use(
}
if (error.response.status >= 500) {
openBackendCooldown(`http_${error.response.status}`);
// Do NOT trigger cooldown for application-level 500 errors (e.g. TTS failures).
// Cooldown should only block for network connectivity issues (handled above).
// Application 500s should be handled by individual callers.
return Promise.reject(
new ConnectionError('Backend server is experiencing issues. Please try again later.')
new ConnectionError(`Server error ${error.response.status}: ${error.response.statusText || 'Internal Server Error'}`)
);
}

View File

@@ -5,7 +5,9 @@ import { useSubscription } from "../../contexts/SubscriptionContext";
import { podcastApi } from "../../services/podcastApi";
import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl";
import { getLatestBrandAvatar } from "../../api/brandAssets";
import { VoiceSelector } from "../shared/VoiceSelector";
import { VoiceSelector, VOICE_CLONE_ID } from "../shared/VoiceSelector";
import { getLatestVoiceClone } from "../../api/brandAssets";
import { setCachedVoiceCloneInfo } from "../../services/podcastApi";
// Imported Components
import { TopicUrlInput, TOPIC_PLACEHOLDERS } from "./CreateStep/TopicUrlInput";
@@ -316,9 +318,43 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
}
// Include selected voice in knobs
const finalKnobs = {
// If voice clone is selected, include voice clone metadata
const isVoiceClone = selectedVoiceId === VOICE_CLONE_ID || knobs.custom_voice_id === selectedVoiceId;
let voiceSampleUrl: string | undefined;
let voiceCloneEngine: string | undefined;
let customVoiceId: string | undefined;
if (isVoiceClone) {
try {
const voiceCloneInfo = await getLatestVoiceClone();
if (voiceCloneInfo?.success && voiceCloneInfo.custom_voice_id) {
customVoiceId = voiceCloneInfo.custom_voice_id;
voiceSampleUrl = voiceCloneInfo.preview_audio_url;
voiceCloneEngine = voiceCloneInfo.engine || "qwen3";
// Cache for reuse across scenes
setCachedVoiceCloneInfo({
customVoiceId,
voiceSampleUrl,
engine: voiceCloneEngine,
isVoiceClone: true,
});
}
} catch (e) {
console.warn("[CreateModal] Could not fetch voice clone info:", e);
}
} else {
// Clear cache if system voice selected
setCachedVoiceCloneInfo({ isVoiceClone: false });
}
const finalKnobs: Knobs = {
...knobs,
voice_id: selectedVoiceId,
voice_id: isVoiceClone ? "Wise_Woman" : selectedVoiceId,
custom_voice_id: customVoiceId,
is_voice_clone: isVoiceClone,
voice_sample_url: voiceSampleUrl,
voice_clone_engine: voiceCloneEngine,
};
try {

View File

@@ -384,6 +384,11 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [analysisStarted, setAnalysisStarted] = useState(false);
const [progressIndex, setProgressIndex] = useState(0);
// Track previous isSubmitting value at component level (not inside effect)
const prevIsSubmittingRef = useRef(isSubmitting);
const [analysisCompleteRef, setAnalysisCompleteRef] = useState(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
@@ -393,10 +398,6 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
}, []);
// Close modal only AFTER analysis fully completes (wait for project/analysis to be set)
// Use a ref to track previous isSubmitting to detect the transition from true to false
const prevIsSubmittingRef = useRef(isSubmitting);
const [analysisCompleteRef, setAnalysisCompleteRef] = useState(false);
useEffect(() => {
// Track if analysis transitioned from true to false (completed)
const wasSubmitting = prevIsSubmittingRef.current;
@@ -424,7 +425,7 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
if (error && showAnalysisModal) {
console.warn('[CreateActions] Error detected — keeping modal open:', error);
}
}, [isSubmitting, showAnalysisModal, analysisStarted, onAnnouncementClear, error, analysisCompleteRef]);
}, [isSubmitting, showAnalysisModal, analysisStarted, onAnnouncementClear, error]);
// Sequential progress - increment every few seconds
useEffect(() => {

View File

@@ -20,6 +20,7 @@ import {
DEFAULT_KNOBS,
getStepLabel,
} from "./PodcastDashboard/index";
import { ScriptGenerationProgressView } from "./PodcastDashboard/ScriptGenerationProgressView";
const PodcastDashboard: React.FC = () => {
useEffect(() => {
@@ -400,6 +401,69 @@ const PodcastDashboard: React.FC = () => {
</Button>
</DialogActions>
</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>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useCallback } from "react";
import { Stack, Typography, Chip, Divider, Box, alpha, Paper, CircularProgress } from "@mui/material";
import React, { useCallback } from "react";
import { Stack, Typography, Chip, Divider, Box, alpha, Paper, CircularProgress, Tooltip } from "@mui/material";
import {
Insights as InsightsIcon,
Search as SearchIcon,
@@ -7,8 +7,9 @@ import {
Article as ArticleIcon,
AutoAwesome as AutoAwesomeIcon,
ArrowForward as ArrowForwardIcon,
HelpOutline as HelpOutlineIcon,
} from "@mui/icons-material";
import { Research, ResearchInsight } from "../types";
import { Research, ResearchInsight, Fact } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { FactCard } from "../FactCard";
import { TextToSpeechButton } from "../../shared/TextToSpeechButton";
@@ -26,6 +27,27 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
onGenerateScript,
isGeneratingScript = false,
}) => {
const getSourceFact = (idx: number): Fact | undefined => {
const factCards = research.factCards || [];
return factCards.find(f => f.id === `source-${idx}`);
};
// Strip markdown for text-to-speech
const stripMarkdown = (text: string): string => {
if (!text) return '';
return text
.replace(/#{1,6}\s+/g, '') // Headers
.replace(/\*\*(.*?)\*\*/g, '$1') // Bold
.replace(/\*(.*?)\*/g, '$1') // Italic
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // Links
.replace(/`{1,3}(.*?)`{1,3}/g, '$1') // Code
.replace(/^\s*[-*+]\s+/gm, '') // List items
.replace(/^\s*\d+\.\s+/gm, '') // Numbered list
.replace(/\n{2,}/g, '. ') // Multiple newlines to periods
.replace(/\n/g, ' ') // Single newlines to spaces
.trim();
};
// Simple markdown-to-HTML converter
const renderMarkdown = useCallback((text: string) => {
if (!text) return null;
@@ -150,7 +172,7 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1rem" }} />
Executive Summary
<Box sx={{ ml: 'auto' }}>
<TextToSpeechButton text={research.summary} size="small" showSettings />
<TextToSpeechButton text={stripMarkdown(research.summary)} size="small" showSettings />
</Box>
</Typography>
<Box sx={{
@@ -187,28 +209,75 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
borderRadius: 2,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700 }}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5, width: '100%' }}>
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700, flex: 1 }}>
{insight.title}
</Typography>
<TextToSpeechButton text={stripMarkdown(insight.content)} size="small" />
{insight.source_indices && insight.source_indices.length > 0 && (
<Stack direction="row" spacing={0.5}>
{insight.source_indices.map(sIdx => (
<Chip
key={sIdx}
label={`S${sIdx}`}
size="small"
variant="outlined"
sx={{
height: 18,
fontSize: '0.65rem',
fontWeight: 700,
borderColor: alpha("#667eea", 0.3),
color: "#667eea",
bgcolor: alpha("#667eea", 0.05)
}}
/>
))}
{insight.source_indices.map(sIdx => {
const source = research.sources?.[sIdx - 1];
const fact = getSourceFact(sIdx);
return (
<Tooltip
key={sIdx}
title={
<Box sx={{ p: 0.5 }}>
{fact ? (
<Box>
<Typography variant="caption" display="block" fontWeight={700} sx={{ color: '#A5B4FC', mb: 0.5 }}>
Source S{sIdx}
</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>
@@ -259,17 +328,10 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
{/* Expert Quotes */}
<Box sx={{ mb: 3 }}>
<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
<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>
{research.expertQuotes && research.expertQuotes.length > 0 ? (
<Stack spacing={1.5}>
@@ -286,38 +348,69 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
"{quote.quote}"
</Typography>
<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
label={`Source S${quote.source_index}`}
size="small"
clickable
component="a"
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
href={sourceUrl || undefined}
target={sourceUrl ? "_blank" : undefined}
rel={sourceUrl ? "noopener noreferrer" : undefined}
sx={{
background: 'linear-gradient(135deg, #8B5CF6 0%, #7C3AED 100%)',
color: '#fff',
fontWeight: 600,
fontSize: '0.7rem',
cursor: 'pointer',
cursor: sourceUrl ? 'pointer' : 'default',
textDecoration: 'none',
'&:hover': {
background: 'linear-gradient(135deg, #7C3AED 0%, #6D28D9 100%)',
},
}}
/>
) : (
<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',
}}
/>
)}
</Tooltip>
</Box>
</Paper>
);
@@ -333,17 +426,10 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
{/* Listener CTAs */}
<Box sx={{ mb: 3 }}>
<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
<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>
{research.listenerCta && research.listenerCta.length > 0 ? (
<Stack spacing={1.5}>
@@ -370,17 +456,10 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
{/* Mapped Angles */}
<Box sx={{ mb: 3 }}>
<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
<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>
{research.mappedAngles && research.mappedAngles.length > 0 ? (
<Stack spacing={1.5}>
@@ -405,54 +484,7 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
No mapped angles available yet.
</Typography>
)}
</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>
</Box>
{/* Search Queries Used */}
{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

@@ -61,6 +61,11 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
const [duplicateProjectInfo, setDuplicateProjectInfo] = useState<{projectId: string; idea: string}>({ projectId: "", idea: "" });
// Script Generation Modal State
const [showScriptGenModal, setShowScriptGenModal] = useState(false);
const [scriptGenStarted, setScriptGenStarted] = useState(false);
const [scriptGenProgressIndex, setScriptGenProgressIndex] = useState(0);
const budgetTracking = useBudgetTracking(budgetCap || 50);
const preflightCheck = usePreflightCheck({
@@ -94,6 +99,47 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
return undefined;
}, [announcement]);
const prevIsGeneratingScriptRef = useRef(false);
// Sequential progress for script generation modal
useEffect(() => {
if (!showScriptGenModal || !scriptGenStarted) {
setScriptGenProgressIndex(0);
return;
}
const interval = setInterval(() => {
setScriptGenProgressIndex((prev) => {
if (prev < 3) { // 4 steps total (0-3)
return prev + 1;
}
return prev;
});
}, 3000);
return () => clearInterval(interval);
}, [showScriptGenModal, scriptGenStarted]);
// Handle modal close when script generation completes
useEffect(() => {
const wasSubmitting = prevIsGeneratingScriptRef.current;
const nowNotSubmitting = !isGeneratingScript;
// Only close modal if:
// 1. Modal is still shown
// 2. scriptGenStarted is true
// 3. isGeneratingScript transitioned from true to false
// 4. AND we're not showing an error (scriptData is set on success)
if (showScriptGenModal && scriptGenStarted && wasSubmitting && nowNotSubmitting && !announcement.includes("failed")) {
setTimeout(() => {
setShowScriptGenModal(false);
}, 500);
}
// Update ref for next render
prevIsGeneratingScriptRef.current = isGeneratingScript;
}, [isGeneratingScript, showScriptGenModal, scriptGenStarted, announcement]);
const handleCreate = useCallback(async (payload: CreateProjectPayload, feedback?: string) => {
if (isAnalyzing) return;
setResearch(null);
@@ -327,20 +373,12 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
return;
}
setPreflightOperationName("Research");
// Note: Preflight is handled inside podcastApi.runExaResearch (ensurePreflight)
// No need to call it twice here
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
console.log('[Research] User selected queries:', Array.from(selectedQueries));
console.log('[Research] Filtered approvedQueries for API:', approvedQueries.map(q => q.query));
const preflightResult = await preflightCheck.check({
provider: researchProvider === "exa" ? "exa" : "gemini",
operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding",
tokens_requested: researchProvider === "exa" ? 0 : 1200,
actual_provider_name: researchProvider || "exa",
});
if (!preflightResult.can_proceed) {
return;
}
try {
setIsResearching(true);
@@ -395,45 +433,44 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
} finally {
setIsResearching(false);
}
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setEstimate, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]);
}, [isResearching, project, selectedQueries, queries, researchProvider, analysis, setResearch, setRawResearch, setEstimate, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]);
// Add a ref to track if we're currently generating to prevent double calls
const isGeneratingRef = useRef(false);
const handleGenerateScript = useCallback(async () => {
// Guard against double calls
if (isGeneratingRef.current) {
// CRITICAL: Guard against double calls - set IMMEDIATELY to prevent concurrent clicks
if (isGeneratingRef.current || isGeneratingScript) {
console.log('[ScriptGen] Already generating, skipping duplicate call');
return;
}
if (showScriptEditor) return;
// Prevent if script already exists or render phase started
if (showScriptEditor || projectState.scriptData) {
console.log('[ScriptGen] Script already exists, skipping');
return;
}
if (!project || !research) {
setAnnouncement("Project or research missing — cannot generate script");
return;
}
// Mark as generating immediately (both ref and state)
// Mark as generating immediately BEFORE any async calls (both ref and state)
isGeneratingRef.current = true;
setIsGeneratingScript(true);
// Show modal IMMEDIATELY to prevent duplicate clicks
setShowScriptGenModal(true);
setScriptGenStarted(true);
setScriptGenProgressIndex(0);
console.log('[ScriptGen] Modal shown, generating ref set');
setPreflightOperationName("Script Generation");
const preflightResult = await preflightCheck.check({
provider: "gemini",
operation_type: "script_generation",
tokens_requested: 2000,
actual_provider_name: "gemini",
});
if (!preflightResult.can_proceed) {
isGeneratingRef.current = false; // Reset on preflight failure
setIsGeneratingScript(false); // Reset loading state on preflight failure
return;
}
// Note: Preflight is also called inside podcastApi.generateScript (ensurePreflight)
// No need to call it twice - the API layer handles it
setScriptData(null);
setShowRenderQueue(false);
setShowScriptEditor(true);
setAnnouncement("Generating script with AI... Creating scenes and dialogue based on your research...");
try {
@@ -464,6 +501,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
console.log('[ScriptGen] Script generated:', { sceneCount: result.scenes?.length });
setScriptData(result);
setShowScriptEditor(true); // Open editor after successful generation
setIsGeneratingScript(false);
setAnnouncement("Script generated! Review and edit your scenes below.");
} catch (error) {
@@ -472,7 +510,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
} finally {
isGeneratingRef.current = false; // Reset when done
}
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible, analysis])
}, [showScriptEditor, project, research, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible, analysis, setShowScriptGenModal, scriptGenStarted, setScriptGenProgressIndex, isGeneratingScript, projectState.scriptData, currentStep])
const handleProceedToRendering = useCallback((script: Script) => {
// Clear media cache for all scenes before proceeding to remove old blobs
@@ -608,6 +646,11 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
duplicateProjectInfo,
activeStep,
canGenerateScript,
// Script Generation Modal
showScriptGenModal,
setShowScriptGenModal,
scriptGenStarted,
scriptGenProgressIndex,
// Handlers
handleCreate,
handleRegenerate,

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Script, Knobs, Job, RenderJobResult, TaskStatus, VideoGenerationSettings } from "../types";
import { podcastApi } from "../../../services/podcastApi";
import { podcastApi, getCachedVoiceCloneInfo } from "../../../services/podcastApi";
interface UseRenderQueueProps {
script: Script;
@@ -427,9 +427,14 @@ export const useRenderQueue = ({
});
try {
const cachedClone = getCachedVoiceCloneInfo();
const result: RenderJobResult = await podcastApi.renderSceneAudio({
scene,
voiceId: "Wise_Woman",
voiceId: knobs.voice_id || "Wise_Woman",
customVoiceId: knobs.custom_voice_id || cachedClone?.customVoiceId,
useVoiceClone: knobs.is_voice_clone || cachedClone?.isVoiceClone || false,
voiceSampleUrl: knobs.voice_sample_url || cachedClone?.voiceSampleUrl || undefined,
voiceCloneEngine: knobs.voice_clone_engine || cachedClone?.engine || undefined,
emotion: scene.emotion || getSceneVoiceEmotion(knobs),
speed: knobs.voice_speed,
});

View File

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

View File

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

View File

@@ -7,8 +7,9 @@ import { podcastApi } from "../../../services/podcastApi";
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
import { SceneEditor } from "./SceneEditor";
import { InlineAudioPlayer } from "../InlineAudioPlayer";
import { aiApiClient } from "../../../api/client";
import { aiApiClient, getApiUrl } from "../../../api/client";
import { BrollInfoPanel } from "./parts/BrollInfoPanel";
import { ScriptEditorProvider } from "./ScriptEditorContext";
interface ScriptEditorProps {
projectId: string;
@@ -75,49 +76,8 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
}
}, [initialScript]);
useEffect(() => {
// If script already exists, don't regenerate
if (script) {
return;
}
// Only generate if we have research data
if (!rawResearch) {
return;
}
let mounted = true;
setLoading(true);
setError(null);
podcastApi
.generateScript({
projectId,
idea,
research: rawResearch,
knobs,
speakers,
durationMinutes,
podcastMode,
analysis,
outline,
})
.then((res) => {
if (mounted) {
setScript(res);
emitScriptChange(res);
setError(null);
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to generate script";
setError(message);
onError(message);
})
.finally(() => mounted && setLoading(false));
return () => {
mounted = false;
};
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes, podcastMode, analysis, outline, emitScriptChange, onError, script]);
// Note: Script generation is now handled by ScriptEditorProvider
// to ensure BrollInfoPanel and other child components have access to context
const updateScene = (updated: Scene) => {
// Use functional update to ensure we're working with latest state
@@ -309,14 +269,20 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
chart_type: scene.chart_data.type || "bar_comparison",
title: scene.title,
});
console.log(`[ChartPreview] Scene ${scene.id}: type=${scene.chart_data.type || 'bar_comparison'}, data=`, scene.chart_data);
const toFullUrl = (url: string) => {
if (/^https?:\/\//i.test(url)) return url;
return `${getApiUrl()}${url.startsWith("/") ? url : `/${url}`}`;
};
return {
...scene,
broll_preview_url: result.preview_url,
broll_preview_url: toFullUrl(result.preview_url),
chart_id: result.chart_id,
};
} catch (error) {
console.error(`Failed to generate chart preview for scene ${scene.id}:`, error);
console.error(`[ChartPreview] Failed for scene ${scene.id}:`, error);
return scene;
}
})
@@ -379,11 +345,28 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
}, [script, emitScriptChange]);
return (
<Box sx={{ mt: 4 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
Back to Research
</SecondaryButton>
<ScriptEditorProvider
projectId={projectId}
idea={idea}
rawResearch={rawResearch}
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 }}>
<Typography
variant="h4"
@@ -945,5 +928,6 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
</Stack>
)}
</Box>
</ScriptEditorProvider>
);
};

View File

@@ -320,6 +320,9 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
scenes: sceneData,
voiceId: knobs.voice_id,
customVoiceId: knobs.custom_voice_id,
useVoiceClone: knobs.is_voice_clone,
voiceSampleUrl: knobs.voice_sample_url,
voiceCloneEngine: knobs.voice_clone_engine,
speed: knobs.voice_speed,
emotion: knobs.voice_emotion,
englishNormalization: true,

View File

@@ -1,6 +1,6 @@
import React from "react";
import { Stack, Box, Typography, Alert, Paper, Button, CircularProgress, Chip } from "@mui/material";
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon } from "@mui/icons-material";
import { Stack, Box, Typography, Paper, Button, CircularProgress, Chip, IconButton, Tooltip } from "@mui/material";
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon, Fullscreen as FullscreenIcon, Visibility as VisibilityIcon } from "@mui/icons-material";
import { useScriptEditor } from "../ScriptEditorContext";
import { Script } from "../../types";
@@ -42,119 +42,246 @@ export const BrollInfoPanel: React.FC<BrollInfoPanelProps> = (props) => {
return (
<Paper
sx={{
p: 3,
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.05) 0%, rgba(16, 185, 129, 0.05) 100%)",
p: 2.5,
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)",
borderRadius: 2,
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} 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" }}>
<BarChartIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
B-Roll Charts
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
Programmatic charts extracted from research data
</Typography>
</Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box sx={{
p: 0.75,
borderRadius: 1.5,
background: "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center"
}}>
<BarChartIcon sx={{ fontSize: 18, color: "#fff" }} />
</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 && (
<Chip
label={`${resolvedScenesWithCharts} scene${resolvedScenesWithCharts > 1 ? 's' : ''} with charts`}
<Button
variant="contained"
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>
{!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" } }}>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
<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.
</Typography>
</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}>
<Button
variant="contained"
startIcon={resolvedGeneratingChartId ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
onClick={resolvedGenerateChartPreviews}
disabled={!!resolvedGeneratingChartId || !resolvedGenerateChartPreviews}
sx={{
background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)",
"&:hover": { background: "linear-gradient(135deg, #16a34a 0%, #059669 100%)" },
textTransform: "none",
fontWeight: 600,
}}
>
{resolvedGeneratingChartId ? "Generating..." : "Generate Chart Previews"}
</Button>
</Stack>
{scenesWithData.map((scene) => (
<Box
key={scene.id}
sx={{
p: 2,
background: "rgba(0,0,0,0.02)",
borderRadius: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{scene.title}
</Typography>
<Typography variant="caption" sx={{ color: "#64748b" }}>
{scene.chart_data?.type || "chart"} {scene.chart_data?.labels?.length || 0} data points
</Typography>
</Box>
<Stack direction="row" spacing={1}>
{resolvedGeneratingChartId === scene.id ? (
<CircularProgress size={20} />
) : scene.broll_preview_url ? (
<>
<Chip
label="Preview Ready"
size="small"
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a" }}
{hasChartData ? (
<Stack spacing={1.5}>
{scenesWithData.map((scene) => {
const chartData = scene.chart_data;
const hasPreview = !!scene.broll_preview_url;
return (
<Box
key={scene.id}
sx={{
p: 1.5,
background: "#fff",
borderRadius: 1.5,
border: "1px solid rgba(0,0,0,0.06)",
display: "flex",
alignItems: "center",
gap: 2,
transition: "all 0.2s ease",
"&:hover": {
borderColor: "rgba(34, 197, 94, 0.3)",
boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
}
}}
>
{/* Thumbnail */}
<Box
sx={{
width: 72,
height: 48,
flexShrink: 0,
borderRadius: 1,
overflow: "hidden",
background: hasPreview ? "rgba(0,0,0,0.04)" : "linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: hasPreview ? "pointer" : "default",
transition: "all 0.2s ease",
"&:hover": hasPreview ? {
transform: "scale(1.05)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
} : {}
}}
>
{resolvedGeneratingChartId === scene.id ? (
<CircularProgress size={24} sx={{ color: "#22c55e" }} />
) : hasPreview && scene.broll_preview_url ? (
<Box
component="img"
src={scene.broll_preview_url}
alt={`Chart for ${scene.title}`}
sx={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
onClick={() => window.open(scene.broll_preview_url, '_blank')}
/>
<Button
size="small"
startIcon={<RefreshIcon />}
) : (
<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"
onClick={() => resolvedRegenerateChart?.(scene.id)}
disabled={!resolvedRegenerateChart}
disabled={!resolvedRegenerateChart || !!resolvedGeneratingChartId}
sx={{
color: "#64748b",
"&:hover": { color: "#f59e0b", background: "rgba(245, 158, 11, 0.1)" }
}}
>
Regenerate
</Button>
<Button
size="small"
startIcon={<DeleteIcon />}
<RefreshIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Remove chart">
<IconButton
size="small"
onClick={() => resolvedRemoveChart?.(scene.id)}
disabled={!resolvedRemoveChart}
sx={{ color: "#ef4444" }}
sx={{
color: "#64748b",
"&:hover": { color: "#ef4444", background: "rgba(239, 68, 68, 0.1)" }
}}
>
Remove
</Button>
</>
) : null}
</Stack>
</Box>
))}
<DeleteIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
</Stack>
</Box>
);
})}
</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>
);
};
};

View File

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

View File

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

View File

@@ -34,6 +34,10 @@ try {
export type AudioGenerationSettings = {
voiceId: string;
customVoiceId?: string;
useVoiceClone?: boolean;
voiceSampleUrl?: string;
voiceCloneEngine?: string;
speed: number;
volume: 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" },
];
const VOICE_CLONE_ID = "MY_VOICE_CLONE";
export const VOICE_CLONE_ID = "MY_VOICE_CLONE";
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
value,

View File

@@ -60,6 +60,9 @@ export interface PodcastProjectState {
// Backend project creation status — prevents 404 sync calls before project exists
backendProjectCreated?: boolean;
// Track last synced phase to prevent duplicate syncs
lastSyncedPhase?: string | null;
}
const DEFAULT_KNOBS: Knobs = {
@@ -162,21 +165,28 @@ export const usePodcastProjectState = () => {
}
}, [state]);
// Sync to database after major steps (debounced)
// Sync to database ONLY on phase transitions (not on every state change)
// This ensures we sync at: Create → Analyze → Research → Script → Render
useEffect(() => {
if (!state.project || !state.project.id || !state.backendProjectCreated) return;
if (!state.currentStep) return;
// Skip if already synced this phase (handles duplicate calls from handleCreate/etc)
if (state.currentStep === state.lastSyncedPhase) {
return;
}
// Capture project ID to avoid closure issues
const projectId = state.project.id;
// Clear existing timeout
// Debounce - wait for state to settle before syncing
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
// Debounce database sync (wait 2 seconds after last change)
syncTimeoutRef.current = setTimeout(async () => {
try {
console.log(`[Sync] Saving project at phase: ${state.currentStep}`);
const dbState = {
analysis: state.analysis,
queries: state.queries,
@@ -195,39 +205,37 @@ export const usePodcastProjectState = () => {
status: state.currentStep === 'render' && state.renderJobs.every(j => j.status === 'completed') ? 'completed' : 'in_progress',
};
await podcastApi.saveProject(projectId, dbState);
const saved = await podcastApi.saveProject(projectId, dbState);
if (saved) {
setState((prev) => ({ ...prev, lastSyncedPhase: prev.currentStep }));
console.log(`[Sync] Project saved successfully at phase: ${state.currentStep}`);
} else {
console.warn(`[Sync] Failed to save project at phase: ${state.currentStep} - will retry on next phase change`);
}
} catch (error) {
console.error('Error syncing project to database:', error);
// Don't throw - localStorage is still working
console.error('[Sync] Error saving project:', error);
}
}, 2000);
}, 1500);
return () => {
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
};
}, [
state.project,
state.analysis,
state.queries,
state.selectedQueries,
state.research,
state.rawResearch,
state.estimate,
state.scriptData,
state.renderJobs,
state.knobs,
state.bible,
state.researchProvider,
state.showScriptEditor,
state.showRenderQueue,
state.currentStep,
]);
// Only sync when phase changes - not on every state field change
}, [state.currentStep, state.backendProjectCreated]);
// Setters
const setProject = useCallback((project: PodcastProjectState['project']) => {
setState((prev) => ({ ...prev, project, currentStep: project ? 'analysis' : null, updatedAt: new Date().toISOString() }));
const newStep = project ? 'analysis' : null;
setState((prev) => ({
...prev,
project,
currentStep: newStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString()
}));
}, []);
const setAnalysis = useCallback((analysis: PodcastProjectState['analysis']) => {
@@ -235,6 +243,7 @@ export const usePodcastProjectState = () => {
...prev,
analysis,
currentStep: analysis ? 'research' : prev.currentStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString()
}));
}, []);
@@ -255,6 +264,7 @@ export const usePodcastProjectState = () => {
...prev,
research,
currentStep: research ? 'script' : prev.currentStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString()
}));
}, []);
@@ -272,6 +282,7 @@ export const usePodcastProjectState = () => {
...prev,
scriptData,
currentStep: scriptData ? 'render' : prev.currentStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString()
}));
}, []);

View File

@@ -93,9 +93,14 @@ billingAPI.interceptors.response.use(
async (error) => {
const originalRequest = error.config;
// Handle network errors
// Handle network errors - but NOT timeouts (backend might just be slow)
if (!error.response) {
noteBackendUnavailable(error?.message || 'billing_network_error');
const errorMsg = error?.message || '';
const isTimeout = errorMsg.includes('timeout') || errorMsg.includes('ETIMEDOUT');
if (!isTimeout) {
noteBackendUnavailable(errorMsg || 'billing_network_error');
}
console.error('Billing API Network Error:', error.message);
return Promise.reject(error);
}

View File

@@ -1,3 +1,4 @@
import { noteBackendRecovered } from "../api/client";
import { ResearchProvider, ResearchConfig } from "./blogWriterApi";
import {
storyWriterApi,
@@ -28,12 +29,42 @@ const DEFAULT_KNOBS: Knobs = {
voice_speed: 1,
voice_id: "Wise_Woman",
custom_voice_id: undefined,
is_voice_clone: undefined,
voice_sample_url: undefined,
voice_clone_engine: undefined,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
// In-memory cache for voice clone info to avoid re-fetching per scene
let _voiceCloneCache: {
customVoiceId?: string;
voiceSampleUrl?: string;
engine?: string;
isVoiceClone?: boolean;
timestamp: number;
} | null = null;
const VOICE_CLONE_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
export function getCachedVoiceCloneInfo() {
if (_voiceCloneCache && Date.now() - _voiceCloneCache.timestamp < VOICE_CLONE_CACHE_TTL) {
return _voiceCloneCache;
}
_voiceCloneCache = null;
return null;
}
export function setCachedVoiceCloneInfo(info: {
customVoiceId?: string;
voiceSampleUrl?: string;
engine?: string;
isVoiceClone?: boolean;
}) {
_voiceCloneCache = { ...info, timestamp: Date.now() };
}
// const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const createId = (prefix: string) => {
@@ -244,9 +275,9 @@ const mapExaResearchResponse = (response: any): Research => {
};
const ensurePreflight = async (operation: PreflightOperation) => {
console.log('[podcastApi] Running preflight for:', operation);
console.log('[podcastApi] Running preflight for:', operation.operation_type);
const result = await checkPreflight(operation);
console.log('[podcastApi] Preflight result:', result);
console.log('[podcastApi] Preflight result: can_proceed=', result.can_proceed);
if (!result.can_proceed) {
const message = result.operations[0]?.message || "Pre-flight validation failed";
throw new Error(message);
@@ -379,7 +410,9 @@ export const podcastApi = {
bible: params.bible,
analysis: params.analysis,
}, { timeout: 300000 }); // 5 minute timeout for research
console.log('[podcastApi] Exa research response received:', response.status, response.data);
const sourceCount = response.data?.sources?.length || 0;
const insightCount = response.data?.key_insights?.length || 0;
console.log(`[podcastApi] Exa research response: status=${response.status}, sources=${sourceCount}, insights=${insightCount}`);
} catch (error: any) {
console.error('[podcastApi] Exa research error:', error?.response?.status, error?.response?.data, error?.message);
throw error;
@@ -497,6 +530,9 @@ export const podcastApi = {
scene: Scene;
voiceId?: string;
customVoiceId?: string;
useVoiceClone?: boolean;
voiceSampleUrl?: string;
voiceCloneEngine?: string;
emotion?: string; // Fallback if scene doesn't have emotion
speed?: number;
volume?: number;
@@ -600,7 +636,7 @@ export const podcastApi = {
channel: params.channel || null,
format: params.format || null,
language_boost: params.languageBoost || null,
});
}, { timeout: 300000 }); // 5 minute timeout for voice clone / TTS
return {
audioUrl: response.data.audio_url,
@@ -623,12 +659,14 @@ export const podcastApi = {
},
// Project persistence endpoints
async saveProject(projectId: string, state: any): Promise<void> {
async saveProject(projectId: string, state: any): Promise<boolean> {
try {
await aiApiClient.put(`/api/podcast/projects/${projectId}`, state);
return true;
} catch (error) {
console.error("Failed to save project to database:", error);
// Don't throw - localStorage fallback is acceptable
noteBackendRecovered();
return false;
}
},
@@ -952,6 +990,9 @@ export const podcastApi = {
scenes: { id: string; title: string; lines: { text: string }[] }[];
voiceId: string;
customVoiceId?: string;
useVoiceClone?: boolean;
voiceSampleUrl?: string;
voiceCloneEngine?: string;
speed: number;
emotion: string;
englishNormalization?: boolean;