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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface VoiceCloneResponse {
|
||||
voice_name?: string;
|
||||
preview_audio_url?: string;
|
||||
asset_id?: number;
|
||||
engine?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -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'}`)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,6 +34,10 @@ try {
|
||||
|
||||
export type AudioGenerationSettings = {
|
||||
voiceId: string;
|
||||
customVoiceId?: string;
|
||||
useVoiceClone?: boolean;
|
||||
voiceSampleUrl?: string;
|
||||
voiceCloneEngine?: string;
|
||||
speed: number;
|
||||
volume: number;
|
||||
pitch: number;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}));
|
||||
}, []);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user