Compare commits
53 Commits
codex/remo
...
fix/add-ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3150941c36 | ||
|
|
3f984e8d0c | ||
|
|
a7d2ef1c09 | ||
|
|
fc47445181 | ||
|
|
d518365c87 | ||
|
|
ba94ee30bc | ||
|
|
8b79099b15 | ||
|
|
fbbfe81ed7 | ||
|
|
d7319c981e | ||
|
|
3c4965462a | ||
|
|
26ccb2f609 | ||
|
|
cbd68fa43f | ||
|
|
641143a7d6 | ||
|
|
dd7f8515a4 | ||
|
|
5e205d52cd | ||
|
|
b9f2123ce9 | ||
|
|
00f46ecbed | ||
|
|
973dd501fe | ||
|
|
efff72f4bd | ||
|
|
913e59a0a8 | ||
|
|
02d13716f3 | ||
|
|
c5d625945f | ||
|
|
6e9c11744c | ||
|
|
b1ca29f7f7 | ||
|
|
91b2f996fd | ||
|
|
7637babd7d | ||
|
|
1deed48484 | ||
|
|
afdbc78779 | ||
|
|
294c64877d | ||
|
|
4a4b8c5a24 | ||
|
|
625dd550d3 | ||
|
|
7f7279f903 | ||
|
|
e68c289901 | ||
|
|
f748c081c2 | ||
|
|
cf70261658 | ||
|
|
7241874545 | ||
|
|
35ebf8c077 | ||
|
|
7aead3ae7d | ||
|
|
80cdd7ff29 | ||
|
|
a9dd9afba1 | ||
|
|
eaea1ee793 | ||
|
|
6db378beff | ||
|
|
7c2a185a29 | ||
|
|
17c046c51e | ||
|
|
ba9ddbf368 | ||
|
|
bfa1b028b3 | ||
|
|
0cac25751f | ||
|
|
a486f4c4fa | ||
|
|
34f82c43dd | ||
|
|
95edd7d470 | ||
|
|
280159669b | ||
|
|
5f13ee5f7b | ||
|
|
e71cf65802 |
46
.planning/PROJECT.md
Normal file
46
.planning/PROJECT.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# ALwrity Project
|
||||
|
||||
## What This Is
|
||||
ALwrity is an AI-powered content creation platform that helps users generate various types of content including podcasts, videos, blogs, and social media content. The platform features a React frontend and a FastAPI backend with onboarding workflows, API key management, and content generation capabilities.
|
||||
|
||||
## Core Value
|
||||
To provide an all-in-one AI content creation suite that simplifies the content production process for creators, marketers, and businesses.
|
||||
|
||||
## Current Focus
|
||||
Based on recent git commits, the team has been working on:
|
||||
- Podcast production features (voice cloning, avatar generation, B-roll integration)
|
||||
- Onboarding flow improvements
|
||||
- Backend stability and debugging
|
||||
- Frontend UI/UX enhancements
|
||||
|
||||
## Requirements
|
||||
|
||||
### Validated
|
||||
- User authentication (Clerk)
|
||||
- API key management for AI providers
|
||||
- Basic podcast generation workflow
|
||||
- File storage and media handling
|
||||
|
||||
### Active
|
||||
- Podcast script generation and editing
|
||||
- Voice cloning and avatar creation
|
||||
- B-roll scene rendering and integration
|
||||
- Onboarding flow completion tracking
|
||||
- API endpoint stability and debugging
|
||||
|
||||
### Out of Scope
|
||||
- Mobile applications (currently web-only)
|
||||
- Enterprise team collaboration features
|
||||
- Advanced analytics dashboard
|
||||
|
||||
## Key Decisions
|
||||
- Using FastAPI for backend performance
|
||||
- React with Material-UI for frontend consistency
|
||||
- Modular API design for extensibility
|
||||
- Database-first approach for persistence
|
||||
|
||||
## Constraints
|
||||
- Must maintain backward compatibility with existing API
|
||||
- Deployment targets include both development and production environments
|
||||
- Must support multiple AI providers (OpenAI, HuggingFace, etc.)
|
||||
- Budget-conscious resource usage for AI API calls
|
||||
40
.planning/STATE.md
Normal file
40
.planning/STATE.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Project State
|
||||
|
||||
## Project Reference
|
||||
**Core Value**: ALwrity is an AI-powered content creation platform that helps users generate various types of content including podcasts, videos, blogs, and social media content.
|
||||
|
||||
**Current Focus**: Based on recent development activity, the team is implementing Phase 2 of the WaveSpeed AI integration roadmap - Hyper-Personalization features for the Persona system, including voice training and avatar creation.
|
||||
|
||||
## Current Position
|
||||
**Phase**: 2 of 3 - Hyper-Personalization
|
||||
**Plan**: 3 of 5 - Persona Avatar Creation & Integration
|
||||
**Status**: In Progress - Working on avatar service implementation and frontend UI for avatar creation
|
||||
|
||||
## Progress
|
||||
Progress: [███████░░] 70%
|
||||
|
||||
## Recent Decisions
|
||||
1. **Avatar Service Architecture**: Decided to create a shared avatar service in backend/services/wavespeed/avatar/ for reuse across LinkedIn and Persona modules
|
||||
2. **UI Framework**: Continuing with Material-UI (MUI) for consistent avatar creation interface
|
||||
3. **Storage Strategy**: Using cloud storage for avatar assets with metadata tracking in PostgreSQL
|
||||
4. **Generation Queue**: Implementing asynchronous processing for avatar generation to prevent API timeouts
|
||||
|
||||
## Pending Todos
|
||||
- [ ] Complete avatar generation API endpoints
|
||||
- [ ] Implement avatar library management UI
|
||||
- [ ] Add avatar preview functionality
|
||||
- [ ] Create avatar upload/download capabilities
|
||||
- [ ] Integrate avatar selection into Persona dashboard
|
||||
- [ ] Add usage tracking and cost estimation for avatar generation
|
||||
- [ ] Write comprehensive tests for avatar service
|
||||
- [ ] Update documentation for avatar feature
|
||||
|
||||
## Blockers/Concerns
|
||||
- **WaveSpeed API Rate Limits**: Need to implement proper queuing and retry mechanisms
|
||||
- **Storage Costs**: Avatar storage could become expensive at scale - need to implement cleanup policies
|
||||
- **Generation Time**: Avatar generation can take 30-60 seconds - need to improve user experience during wait
|
||||
- **Quality Consistency**: Ensuring generated avatars maintain consistent quality across different inputs
|
||||
|
||||
Last session: 2026-04-21 07:02:08
|
||||
Stopped at: Session resumed, proceeding to discuss Phase 2 context
|
||||
Resume file: [updated if applicable]
|
||||
@@ -1,52 +1,104 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
"""
|
||||
Assets Serving Router
|
||||
|
||||
Serves user-uploaded assets (avatars, voice samples) from workspace storage.
|
||||
Uses authenticated or query-token access for security.
|
||||
Audio MIME types are set correctly based on file extension so browsers
|
||||
can play voice clone previews without NotSupportedError.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from services.database import WORKSPACE_DIR, get_user_db_path
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from loguru import logger
|
||||
from typing import Dict, Any
|
||||
|
||||
from middleware.auth_middleware import get_current_user_with_query_token
|
||||
from api.story_writer.utils.auth import require_authenticated_user
|
||||
from utils.storage_paths import get_repo_root, sanitize_user_id
|
||||
|
||||
router = APIRouter(prefix="/api/assets", tags=["Assets Serving"])
|
||||
|
||||
MIME_MAP = {
|
||||
".wav": "audio/wav",
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
".opus": "audio/opus",
|
||||
".webm": "audio/webm",
|
||||
".m4a": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
".flac": "audio/flac",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_asset_path(user_id: str, category: str, filename: str) -> Path:
|
||||
"""Resolve asset path in user workspace with path-traversal protection."""
|
||||
safe_user_id = sanitize_user_id(user_id)
|
||||
repo_root = get_repo_root()
|
||||
|
||||
file_path = (repo_root / "workspace" / f"workspace_{safe_user_id}" / "assets" / category / filename).resolve()
|
||||
|
||||
workspace_dir = (repo_root / "workspace" / f"workspace_{safe_user_id}").resolve()
|
||||
if not str(file_path).startswith(str(workspace_dir)):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
def _get_media_type(filename: str) -> str:
|
||||
"""Determine MIME type from file extension, with fallback."""
|
||||
ext = Path(filename).suffix.lower()
|
||||
return MIME_MAP.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
@router.get("/{user_id}/avatars/{filename}")
|
||||
async def serve_avatar(user_id: str, filename: str):
|
||||
"""
|
||||
Serve avatar images directly.
|
||||
Public endpoint relying on unguessable filenames.
|
||||
"""
|
||||
# Sanitize user_id (simple check to prevent directory traversal)
|
||||
safe_user_id = "".join(c for c in user_id if c.isalnum() or c in ('-', '_'))
|
||||
if safe_user_id != user_id:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID")
|
||||
async def serve_avatar(
|
||||
user_id: str,
|
||||
filename: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
|
||||
):
|
||||
"""Serve avatar images. Supports auth via Authorization header or ?token= query param."""
|
||||
require_authenticated_user(current_user)
|
||||
|
||||
# Sanitize filename
|
||||
safe_filename = os.path.basename(filename)
|
||||
|
||||
# Construct path
|
||||
# workspace/workspace_{user_id}/assets/avatars/{filename}
|
||||
file_path = Path(WORKSPACE_DIR) / f"workspace_{safe_user_id}" / "assets" / "avatars" / safe_filename
|
||||
file_path = _resolve_asset_path(user_id, "avatars", safe_filename)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
return FileResponse(file_path)
|
||||
media_type = _get_media_type(safe_filename)
|
||||
return FileResponse(file_path, media_type=media_type)
|
||||
|
||||
|
||||
@router.get("/{user_id}/voice_samples/{filename}")
|
||||
async def serve_voice_sample(user_id: str, filename: str):
|
||||
"""
|
||||
Serve voice sample audio files directly.
|
||||
"""
|
||||
# Sanitize user_id
|
||||
safe_user_id = "".join(c for c in user_id if c.isalnum() or c in ('-', '_'))
|
||||
if safe_user_id != user_id:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID")
|
||||
async def serve_voice_sample(
|
||||
user_id: str,
|
||||
filename: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
|
||||
):
|
||||
"""Serve voice sample audio files.
|
||||
|
||||
Supports auth via Authorization header or ?token= query param.
|
||||
The ?token= param is essential for <audio> elements and new Audio()
|
||||
which cannot send Authorization headers.
|
||||
"""
|
||||
require_authenticated_user(current_user)
|
||||
|
||||
# Sanitize filename
|
||||
safe_filename = os.path.basename(filename)
|
||||
|
||||
# Construct path
|
||||
# workspace/workspace_{user_id}/assets/voice_samples/{filename}
|
||||
file_path = Path(WORKSPACE_DIR) / f"workspace_{safe_user_id}" / "assets" / "voice_samples" / safe_filename
|
||||
file_path = _resolve_asset_path(user_id, "voice_samples", safe_filename)
|
||||
|
||||
if not file_path.exists():
|
||||
logger.info(f"[Assets] Voice sample not found: {file_path}")
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
return FileResponse(file_path)
|
||||
media_type = _get_media_type(safe_filename)
|
||||
file_size = file_path.stat().st_size
|
||||
logger.warning(f"[Assets] Serving voice sample: {safe_filename} ({media_type}, {file_size} bytes)")
|
||||
return FileResponse(file_path, media_type=media_type)
|
||||
@@ -9,13 +9,27 @@ from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
from .step4_persona_routes import _extract_user_id
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
|
||||
def _extract_user_id(user: Dict[str, Any]) -> str:
|
||||
"""Extract a stable user ID from Clerk-authenticated user payloads.
|
||||
Prefers 'clerk_user_id' or 'id', falls back to 'user_id', else 'unknown'.
|
||||
"""
|
||||
if not isinstance(user, dict):
|
||||
return 'unknown'
|
||||
return (
|
||||
user.get('clerk_user_id')
|
||||
or user.get('id')
|
||||
or user.get('user_id')
|
||||
or 'unknown'
|
||||
)
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
from utils.file_storage import save_file_safely, generate_unique_filename
|
||||
from services.database import get_db, WORKSPACE_DIR
|
||||
from services.database import get_db
|
||||
from utils.storage_paths import get_user_workspace, sanitize_user_id
|
||||
from utils.asset_tracker import save_asset_to_library
|
||||
from models.content_asset_models import ContentAsset, AssetType, AssetSource
|
||||
from sqlalchemy import desc
|
||||
@@ -73,6 +87,8 @@ async def get_latest_avatar(
|
||||
try:
|
||||
user_id = _extract_user_id(current_user)
|
||||
|
||||
logger.warning(f"[latest-avatar] Looking for avatar for user_id: {user_id}")
|
||||
|
||||
# Search for assets that are either:
|
||||
# 1. Saved with source_module=BRAND_AVATAR_GENERATOR (new)
|
||||
# 2. Saved with source_module=STORY_WRITER but have metadata category='brand_avatar' (legacy)
|
||||
@@ -87,6 +103,8 @@ async def get_latest_avatar(
|
||||
])
|
||||
).order_by(desc(ContentAsset.created_at)).limit(50).all()
|
||||
|
||||
logger.warning(f"[latest-avatar] Found {len(candidates)} candidate(s)")
|
||||
|
||||
asset = None
|
||||
for candidate in candidates:
|
||||
# Check for direct match (new assets)
|
||||
@@ -167,7 +185,7 @@ async def generate_avatar(
|
||||
try:
|
||||
user_id = _extract_user_id(current_user)
|
||||
|
||||
logger.info(f"Generating avatar for user {user_id} with prompt: {request.prompt}")
|
||||
logger.warning(f"Generating avatar for user {user_id} with prompt: {request.prompt}")
|
||||
|
||||
# 1. Generate Image
|
||||
result = await generate_image_with_provider(
|
||||
@@ -217,7 +235,7 @@ async def generate_avatar(
|
||||
content_to_save = base64.b64decode(image_data) if isinstance(image_data, str) else image_data
|
||||
|
||||
# Construct user assets directory
|
||||
user_assets_dir = Path(WORKSPACE_DIR) / f"workspace_{user_id}" / "assets" / "avatars"
|
||||
user_assets_dir = get_user_workspace(user_id) / "assets" / "avatars"
|
||||
|
||||
saved_path, error = save_file_safely(
|
||||
content_to_save,
|
||||
@@ -270,7 +288,7 @@ async def enhance_prompt_route(
|
||||
"""Enhance a simple prompt into a detailed midjourney-style prompt."""
|
||||
try:
|
||||
user_id = _extract_user_id(current_user)
|
||||
logger.info(f"Enhancing prompt for user {user_id}: {request.prompt}")
|
||||
logger.warning(f"Enhancing prompt for user {user_id}: {request.prompt}")
|
||||
|
||||
enhanced_prompt = await enhance_image_prompt(request.prompt, user_id=user_id)
|
||||
|
||||
@@ -294,7 +312,7 @@ async def create_variation_route(
|
||||
"""Generate a variation of an existing avatar."""
|
||||
try:
|
||||
user_id = _extract_user_id(current_user)
|
||||
logger.info(f"Creating variation for user {user_id} with prompt: {prompt}")
|
||||
logger.warning(f"Creating variation for user {user_id} with prompt: {prompt}")
|
||||
|
||||
# Read file
|
||||
file_content = await file.read()
|
||||
@@ -315,7 +333,7 @@ async def create_variation_route(
|
||||
content_to_save = base64.b64decode(image_data)
|
||||
|
||||
# Construct user assets directory
|
||||
user_assets_dir = Path(WORKSPACE_DIR) / f"workspace_{user_id}" / "assets" / "avatars"
|
||||
user_assets_dir = get_user_workspace(user_id) / "assets" / "avatars"
|
||||
|
||||
saved_path, error = save_file_safely(
|
||||
content_to_save,
|
||||
@@ -369,7 +387,7 @@ async def enhance_avatar_route(
|
||||
"""Enhance/Upscale an existing avatar."""
|
||||
try:
|
||||
user_id = _extract_user_id(current_user)
|
||||
logger.info(f"Enhancing avatar for user {user_id}")
|
||||
logger.warning(f"Enhancing avatar for user {user_id}")
|
||||
|
||||
# Read file
|
||||
file_content = await file.read()
|
||||
@@ -389,7 +407,7 @@ async def enhance_avatar_route(
|
||||
content_to_save = base64.b64decode(image_data)
|
||||
|
||||
# Construct user assets directory
|
||||
user_assets_dir = Path(WORKSPACE_DIR) / f"workspace_{user_id}" / "assets" / "avatars"
|
||||
user_assets_dir = get_user_workspace(user_id) / "assets" / "avatars"
|
||||
|
||||
saved_path, error = save_file_safely(
|
||||
content_to_save,
|
||||
@@ -446,13 +464,13 @@ async def create_voice_clone(
|
||||
"""Create a voice clone from an audio file."""
|
||||
try:
|
||||
user_id = _extract_user_id(current_user)
|
||||
logger.info(f"Creating voice clone '{voice_name}' (engine={engine}) for user {user_id}")
|
||||
logger.warning(f"[VoiceClone] Creating voice clone '{voice_name}' (engine={engine}) for user {user_id}")
|
||||
|
||||
# 1. Save uploaded audio file
|
||||
file_content = await file.read()
|
||||
filename = generate_unique_filename("voice_sample", Path(file.filename).suffix.lstrip("."))
|
||||
|
||||
user_voice_dir = Path(WORKSPACE_DIR) / f"workspace_{user_id}" / "assets" / "voice_samples"
|
||||
user_voice_dir = get_user_workspace(user_id) / "assets" / "voice_samples"
|
||||
saved_path, error = save_file_safely(file_content, user_voice_dir, filename)
|
||||
|
||||
if error or not saved_path:
|
||||
@@ -474,7 +492,7 @@ async def create_voice_clone(
|
||||
random_suffix = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
|
||||
custom_voice_id = f"vc_{random_suffix}"
|
||||
|
||||
logger.info(f"Cloning voice with Minimax, ID: {custom_voice_id}")
|
||||
logger.warning(f"Cloning voice with Minimax, ID: {custom_voice_id}")
|
||||
|
||||
# Run blocking call in executor
|
||||
result = await loop.run_in_executor(
|
||||
@@ -489,7 +507,7 @@ async def create_voice_clone(
|
||||
preview_audio_bytes = result.preview_audio_bytes
|
||||
|
||||
elif engine.lower() == "cosyvoice":
|
||||
logger.info("Cloning voice with CosyVoice")
|
||||
logger.warning("Cloning voice with CosyVoice")
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: cosyvoice_voice_clone(
|
||||
@@ -504,7 +522,7 @@ async def create_voice_clone(
|
||||
custom_voice_id = f"vc_cosy_{asset_uuid}"
|
||||
|
||||
else: # qwen3 (default)
|
||||
logger.info("Cloning voice with Qwen3")
|
||||
logger.warning("Cloning voice with Qwen3")
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: qwen3_voice_clone(
|
||||
@@ -520,27 +538,48 @@ async def create_voice_clone(
|
||||
|
||||
# 3. Save Preview Audio (if generated)
|
||||
preview_url = None
|
||||
if preview_audio_bytes:
|
||||
preview_filename = f"preview_{filename}"
|
||||
# Ensure it ends with .wav
|
||||
if not preview_filename.endswith(".wav"):
|
||||
preview_filename = str(Path(preview_filename).with_suffix('.wav'))
|
||||
preview_mime_type = "audio/wav"
|
||||
actual_filename = None # Default if preview save fails
|
||||
|
||||
user_voice_dir = Path(WORKSPACE_DIR) / f"workspace_{user_id}" / "assets" / "voice_samples"
|
||||
if preview_audio_bytes and len(preview_audio_bytes) > 0:
|
||||
from utils.media_utils import detect_audio_format, ensure_audio_extension
|
||||
|
||||
detected_fmt, preview_mime_type = detect_audio_format(preview_audio_bytes)
|
||||
logger.warning(f"[VoiceClone] Detected preview audio format: {detected_fmt} ({preview_mime_type}), {len(preview_audio_bytes)} bytes")
|
||||
|
||||
# Build filename with correct extension based on actual content format
|
||||
original_stem = Path(filename).stem
|
||||
preview_filename = f"preview_{original_stem}"
|
||||
preview_filename = ensure_audio_extension(preview_filename, preview_audio_bytes)
|
||||
|
||||
user_voice_dir = get_user_workspace(user_id) / "assets" / "voice_samples"
|
||||
saved_preview_path, error = save_file_safely(preview_audio_bytes, user_voice_dir, preview_filename)
|
||||
|
||||
if not error and saved_preview_path:
|
||||
preview_url = f"/api/assets/{user_id}/voice_samples/{preview_filename}"
|
||||
# Use actual saved filename (may have UUID suffix added by save_file_safely)
|
||||
actual_filename = saved_preview_path.name
|
||||
preview_url = f"/api/assets/{user_id}/voice_samples/{actual_filename}"
|
||||
logger.warning(f"[VoiceClone] Saved preview: {actual_filename} ({saved_preview_path.stat().st_size} bytes, {preview_mime_type})")
|
||||
|
||||
# Verify file exists
|
||||
if not saved_preview_path.exists():
|
||||
logger.warning(f"[VoiceClone] Preview file does not exist after save: {saved_preview_path}")
|
||||
preview_url = None
|
||||
else:
|
||||
logger.warning(f"[VoiceClone] Failed to save preview audio: {error}")
|
||||
|
||||
# 4. Save to Asset Library
|
||||
# Use the preview file (with corrected .wav extension) as the main asset file
|
||||
has_valid_preview = preview_audio_bytes and len(preview_audio_bytes) > 0 and saved_preview_path
|
||||
stored_filename = actual_filename if has_valid_preview else filename
|
||||
asset_id = save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
file_path=file_path,
|
||||
asset_type="audio",
|
||||
source_module="voice_cloner",
|
||||
filename=filename,
|
||||
file_url=f"/api/assets/{user_id}/voice_samples/{filename}",
|
||||
filename=stored_filename,
|
||||
file_url=f"/api/assets/{user_id}/voice_samples/{stored_filename}",
|
||||
asset_metadata={
|
||||
"voice_name": voice_name,
|
||||
"engine": engine,
|
||||
@@ -555,7 +594,7 @@ async def create_voice_clone(
|
||||
return {
|
||||
"success": True,
|
||||
"custom_voice_id": custom_voice_id,
|
||||
"preview_audio_url": preview_url or f"/api/assets/{user_id}/voice_samples/{filename}",
|
||||
"preview_audio_url": preview_url or f"/api/assets/{user_id}/voice_samples/{stored_filename}",
|
||||
"asset_id": asset_id,
|
||||
"message": "Voice clone created successfully"
|
||||
}
|
||||
@@ -574,7 +613,7 @@ async def create_voice_design(
|
||||
"""Create a voice from text description (Voice Design)."""
|
||||
try:
|
||||
user_id = _extract_user_id(current_user)
|
||||
logger.info(f"Designing voice for user {user_id}")
|
||||
logger.warning(f"Designing voice for user {user_id}")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
@@ -588,9 +627,15 @@ async def create_voice_design(
|
||||
)
|
||||
)
|
||||
|
||||
# Save the result to a temporary file
|
||||
filename = generate_unique_filename("voice_design_preview", "wav")
|
||||
user_voice_dir = Path(WORKSPACE_DIR) / f"workspace_{user_id}" / "assets" / "voice_samples"
|
||||
# Save the result to a file with correct extension based on content
|
||||
from utils.media_utils import detect_audio_format, ensure_audio_extension
|
||||
detected_fmt, mime_type = detect_audio_format(result.preview_audio_bytes)
|
||||
logger.warning(f"[VoiceDesign] Detected audio format: {detected_fmt} ({mime_type})")
|
||||
|
||||
filename = generate_unique_filename("voice_design_preview", detected_fmt)
|
||||
filename = ensure_audio_extension(filename, result.preview_audio_bytes)
|
||||
|
||||
user_voice_dir = get_user_workspace(user_id) / "assets" / "voice_samples"
|
||||
saved_path, error = save_file_safely(result.preview_audio_bytes, user_voice_dir, filename)
|
||||
|
||||
if error or not saved_path:
|
||||
|
||||
@@ -1,666 +0,0 @@
|
||||
# Programmatic B-Roll Composer
|
||||
|
||||
A layered video composition pipeline that assembles AI-generated images, programmatic data charts, Pillow text overlays, and circular-masked avatar videos into a single output MP4. Driven by structured JSON from an LLM, exposed via a FastAPI server.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture overview](#1-architecture-overview)
|
||||
2. [File structure](#2-file-structure)
|
||||
3. [Installation](#3-installation)
|
||||
4. [Core concepts](#4-core-concepts)
|
||||
- 4.1 [The Insight dataclass](#41-the-insight-dataclass)
|
||||
- 4.2 [The SceneAssets dataclass](#42-the-sceneassets-dataclass)
|
||||
- 4.3 [The layer stack](#43-the-layer-stack)
|
||||
- 4.4 [The JSON bridge](#44-the-json-bridge)
|
||||
5. [Asset generators](#5-asset-generators)
|
||||
- 5.1 [Bar chart — make_bar_chart](#51-bar-chart--make_bar_chart)
|
||||
- 5.2 [Line trend — make_line_trend](#52-line-trend--make_line_trend)
|
||||
- 5.3 [Bullet overlay — make_bullet_overlay](#53-bullet-overlay--make_bullet_overlay)
|
||||
- 5.4 [Insight card — make_insight_card](#54-insight-card--make_insight_card)
|
||||
6. [Video effects](#6-video-effects)
|
||||
- 6.1 [Circular avatar mask — apply_circle_mask](#61-circular-avatar-mask--apply_circle_mask)
|
||||
- 6.2 [Ken Burns zoom — ken_burns](#62-ken-burns-zoom--ken_burns)
|
||||
7. [Scene builders](#7-scene-builders)
|
||||
- 7.1 [Data scene — build_data_scene](#71-data-scene--build_data_scene)
|
||||
- 7.2 [Bullet scene — build_bullet_scene](#72-bullet-scene--build_bullet_scene)
|
||||
- 7.3 [Full avatar scene — build_full_avatar_scene](#73-full-avatar-scene--build_full_avatar_scene)
|
||||
8. [Scene dispatcher — dispatch_scene](#8-scene-dispatcher--dispatch_scene)
|
||||
9. [Crossfade transitions](#9-crossfade-transitions)
|
||||
- 9.1 [How crossfade_concat works](#91-how-crossfade_concat-works)
|
||||
- 9.2 [The set_duration gotcha](#92-the-set_duration-gotcha)
|
||||
10. [Master compositor — compose_video](#10-master-compositor--compose_video)
|
||||
11. [FastAPI server](#11-fastapi-server)
|
||||
- 11.1 [Request models](#111-request-models)
|
||||
- 11.2 [Job lifecycle](#112-job-lifecycle)
|
||||
- 11.3 [API endpoints](#113-api-endpoints)
|
||||
12. [Running the project](#12-running-the-project)
|
||||
- 12.1 [Smoke test (no media files needed)](#121-smoke-test-no-media-files-needed)
|
||||
- 12.2 [Full video composition](#122-full-video-composition)
|
||||
- 12.3 [API server](#123-api-server)
|
||||
13. [Calling the API](#13-calling-the-api)
|
||||
14. [Production notes](#14-production-notes)
|
||||
15. [Extending the pipeline](#15-extending-the-pipeline)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture overview
|
||||
|
||||
The pipeline follows a **Layered Composition** model. Rather than generating video in one pass, it assembles independent visual layers — each produced by the cheapest appropriate tool — into a single timeline using MoviePy as the compositor.
|
||||
|
||||
```
|
||||
LLM JSON output
|
||||
│
|
||||
▼
|
||||
dispatch_scene() ← routes visual_cue → builder function
|
||||
│
|
||||
├─ build_data_scene()
|
||||
│ ├─ ImageClip (background) ← AI-generated image
|
||||
│ ├─ ImageClip (chart PNG) ← Matplotlib, transparent bg
|
||||
│ ├─ ImageClip (insight card) ← Pillow RGBA
|
||||
│ └─ VideoFileClip (avatar) ← circular numpy mask
|
||||
│
|
||||
├─ build_bullet_scene()
|
||||
│ ├─ ImageClip (background)
|
||||
│ ├─ ImageClip (bullet overlay) ← Pillow RGBA
|
||||
│ └─ VideoFileClip (avatar)
|
||||
│
|
||||
└─ build_full_avatar_scene()
|
||||
└─ VideoFileClip (full-screen)
|
||||
│
|
||||
▼
|
||||
crossfade_concat() ← dissolve between scenes
|
||||
│
|
||||
▼
|
||||
write_videofile() ← H.264 MP4 via ffmpeg
|
||||
```
|
||||
|
||||
The key design decision: charts and text are **never** rendered by a generative model. Matplotlib produces pixel-perfect data graphics from real numbers; Pillow renders crisp, deterministic text. Only the background and the talking-head avatar come from AI generation, minimising both cost and hallucination risk.
|
||||
|
||||
---
|
||||
|
||||
## 2. File structure
|
||||
|
||||
```
|
||||
.
|
||||
├── broll_composer.py # Core library — all composition logic
|
||||
├── api_server.py # FastAPI wrapper — HTTP interface to the pipeline
|
||||
└── requirements.txt # Python dependencies
|
||||
```
|
||||
|
||||
`broll_composer.py` has no FastAPI dependency and can be imported and called directly from scripts, notebooks, or other web frameworks.
|
||||
|
||||
---
|
||||
|
||||
## 3. Installation
|
||||
|
||||
```bash
|
||||
# System dependency — must be on PATH
|
||||
apt-get install ffmpeg
|
||||
|
||||
# Python packages
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**requirements.txt**
|
||||
|
||||
```
|
||||
moviepy==1.0.3
|
||||
Pillow>=10.0
|
||||
matplotlib>=3.8
|
||||
numpy>=1.26
|
||||
fastapi>=0.111
|
||||
uvicorn[standard]>=0.29
|
||||
python-multipart>=0.0.9
|
||||
```
|
||||
|
||||
MoviePy 1.0.3 is pinned because 2.x introduced breaking API changes to `CompositeVideoClip` and the effects interface. The rest can float within the specified lower bounds.
|
||||
|
||||
---
|
||||
|
||||
## 4. Core concepts
|
||||
|
||||
### 4.1 The Insight dataclass
|
||||
|
||||
Every scene is driven by a single `Insight` object. This is the contract between the LLM and the composition pipeline:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Insight:
|
||||
key_insight: str # Headline text rendered on the insight card
|
||||
supporting_stat: str # Sub-headline rendered below the headline
|
||||
visual_cue: str # Selects which scene builder to use (see §8)
|
||||
audio_tone: str # Passed through for downstream TTS / audio selection
|
||||
chart_data: dict # Data payload consumed by chart generators (see §5)
|
||||
duration: float # Scene length in seconds, default 10.0
|
||||
```
|
||||
|
||||
The `audio_tone` field is not used by the video pipeline itself — it is metadata for whatever system generates or selects the voiceover audio track for the scene.
|
||||
|
||||
### 4.2 The SceneAssets dataclass
|
||||
|
||||
`SceneAssets` carries file paths to the media assets for a given scene:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SceneAssets:
|
||||
background_img: str # Required — path to JPEG or PNG background
|
||||
chart_img: Optional[str] # Populated by dispatch_scene after chart generation
|
||||
avatar_video: Optional[str] # Optional — path to MP4 avatar clip
|
||||
bullet_img: Optional[str] # Reserved for pre-rendered bullet overlays
|
||||
```
|
||||
|
||||
`chart_img` starts as `None` and is written to by `dispatch_scene` after it generates the Matplotlib PNG, so the scene builders receive a fully-populated `SceneAssets` by the time they run.
|
||||
|
||||
### 4.3 The layer stack
|
||||
|
||||
Every scene is a `CompositeVideoClip` — a MoviePy object that renders multiple clips on a shared canvas by alpha-compositing them bottom-to-top. The layer order is consistent across all scene types:
|
||||
|
||||
| Z-order | Layer | Source | Notes |
|
||||
|---------|-------|--------|-------|
|
||||
| 0 (bottom) | Background | AI image + Ken Burns | Darkened to make overlays legible |
|
||||
| 1 | Chart or bullet overlay | Matplotlib or Pillow PNG | Transparent background; fades in |
|
||||
| 2 | Insight card | Pillow RGBA | Positioned at y=820 (near bottom) |
|
||||
| 3 (top) | Avatar circle | MP4 + numpy mask | Bottom-right corner |
|
||||
|
||||
### 4.4 The JSON bridge
|
||||
|
||||
The LLM is prompted to return a structured JSON object — not prose — so the pipeline can consume it without parsing ambiguity:
|
||||
|
||||
```json
|
||||
{
|
||||
"key_insight": "AI tools reduced content cycles by 40%",
|
||||
"supporting_stat": "HubSpot 2026 report — 12% lift in CTR",
|
||||
"visual_cue": "bar_chart_comparison",
|
||||
"audio_tone": "authoritative_and_surprising",
|
||||
"duration": 10.0,
|
||||
"chart_data": {
|
||||
"labels": ["Content Velocity", "CTR", "Engagement", "Cost/Lead"],
|
||||
"before": [30, 22, 18, 60],
|
||||
"after": [72, 34, 41, 38]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`pipeline_from_json()` is the single-call entry point that accepts this JSON string, constructs the dataclasses, runs `dispatch_scene`, and writes the output MP4.
|
||||
|
||||
---
|
||||
|
||||
## 5. Asset generators
|
||||
|
||||
These functions produce static image files (PNG with alpha transparency) that are loaded as `ImageClip` objects in the scene builders. They are completely independent of MoviePy and can be called and previewed without assembling any video.
|
||||
|
||||
### 5.1 Bar chart — `make_bar_chart`
|
||||
|
||||
```python
|
||||
make_bar_chart(data: dict, out_path: str, title: str = "") -> str
|
||||
```
|
||||
|
||||
Produces a side-by-side "before vs after" bar chart using Matplotlib. The critical detail is the renderer configuration and save parameters:
|
||||
|
||||
```python
|
||||
matplotlib.use("Agg") # Non-interactive backend — no display required
|
||||
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||
ax.set_facecolor("none") # Transparent axes background
|
||||
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||
```
|
||||
|
||||
Setting both `facecolor="none"` on the figure and `transparent=True` on `savefig` is necessary because they control different things: the figure background and the PNG alpha channel respectively. Without both, a white box appears behind the chart when it is composited over the video background.
|
||||
|
||||
**Expected `data` shape:**
|
||||
|
||||
```python
|
||||
{
|
||||
"labels": ["Category A", "Category B"], # X-axis labels
|
||||
"before": [30, 22], # Bar heights (left bars)
|
||||
"after": [72, 34] # Bar heights (right bars)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Line trend — `make_line_trend`
|
||||
|
||||
```python
|
||||
make_line_trend(data: dict, out_path: str, title: str = "") -> str
|
||||
```
|
||||
|
||||
Produces a time-series line chart with a translucent fill under the curve (`alpha=0.12`). Suited for growth trends, adoption curves, and any metric tracked over sequential time periods.
|
||||
|
||||
**Expected `data` shape:**
|
||||
|
||||
```python
|
||||
{
|
||||
"x": [2021, 2022, 2023, 2024, 2025], # X-axis values (numeric or strings)
|
||||
"y": [10, 18, 30, 45, 72] # Y-axis values
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Bullet overlay — `make_bullet_overlay`
|
||||
|
||||
```python
|
||||
make_bullet_overlay(lines: list[str], out_path: str,
|
||||
width: int = 900, font_size: int = 32) -> str
|
||||
```
|
||||
|
||||
Renders a list of bullet-point strings onto a semi-transparent dark rounded rectangle using Pillow. The image height is computed dynamically from the number of lines:
|
||||
|
||||
```python
|
||||
img_h = padding * 2 + len(lines) * line_h + 12
|
||||
```
|
||||
|
||||
The fill colour `(10, 10, 10, 185)` gives roughly 73% opacity — dark enough for text legibility over any background, light enough that the background remains visible. The bullet character (`•`) is prepended in Python rather than in the font, so no special Unicode font support is required.
|
||||
|
||||
Font loading tries the DejaVu Sans Bold path common on Debian/Ubuntu systems, falling back to Pillow's built-in bitmap font if the TTF is absent.
|
||||
|
||||
### 5.4 Insight card — `make_insight_card`
|
||||
|
||||
```python
|
||||
make_insight_card(insight: str, stat: str, out_path: str,
|
||||
width: int = 960, height: int = 200) -> str
|
||||
```
|
||||
|
||||
Renders a two-line card: a large bold headline (`font_size=34`) and a smaller supporting stat line (`font_size=20`). A solid red rectangle (`#E63946`) is drawn as a left-edge accent bar — a visual device borrowed from print editorial design that gives the card a distinct identity when overlaid on varied backgrounds.
|
||||
|
||||
The card uses `fill=(10, 10, 10, 200)` — approximately 78% opacity — slightly more opaque than the bullet overlay because the headline text is denser.
|
||||
|
||||
---
|
||||
|
||||
## 6. Video effects
|
||||
|
||||
### 6.1 Circular avatar mask — `apply_circle_mask`
|
||||
|
||||
```python
|
||||
apply_circle_mask(clip: VideoFileClip, diameter: int) -> VideoFileClip
|
||||
```
|
||||
|
||||
Takes an MP4 avatar clip and returns it with a circular alpha mask applied, so only the circle region is visible when the clip is composited over other layers.
|
||||
|
||||
The mask is built using NumPy's `ogrid`, which creates coordinate arrays without materialising a full mesh:
|
||||
|
||||
```python
|
||||
Y, X = np.ogrid[:h, :w]
|
||||
cx, cy = w / 2, h / 2
|
||||
mask_arr = ((X - cx)**2 + (Y - cy)**2 <= (min(w, h) / 2)**2).astype(float)
|
||||
```
|
||||
|
||||
This produces a 2D float array (values 0.0 or 1.0) where all pixels within the inscribed circle are 1 (opaque) and all pixels outside are 0 (transparent). MoviePy requires mask arrays in this float format — it does not accept uint8 or boolean arrays directly.
|
||||
|
||||
The mask array is wrapped in an `ImageClip` with `ismask=True` and the duration is set to match the source clip before calling `clip.set_mask()`.
|
||||
|
||||
**Why not use imagemagick or a pre-made circular PNG?** The numpy approach has no subprocess dependency, works for any input resolution, and the mask is computed once and reused for every frame without disk I/O.
|
||||
|
||||
### 6.2 Ken Burns zoom — `ken_burns`
|
||||
|
||||
```python
|
||||
ken_burns(clip: ImageClip, zoom_ratio: float = 0.08) -> ImageClip
|
||||
```
|
||||
|
||||
Applies a slow continuous zoom-in to a static image clip, creating the illusion of camera movement. This prevents the background from looking visually "dead" during the scene.
|
||||
|
||||
The implementation uses `clip.fl()`, MoviePy's frame-level transform function, which receives both `get_frame` (a callable that returns the frame array at time `t`) and the current time `t`:
|
||||
|
||||
```python
|
||||
def zoom_frame(get_frame, t):
|
||||
frame = get_frame(t)
|
||||
frac = 1 + zoom_ratio * (t / clip.duration) # grows from 1.0 to 1+zoom_ratio
|
||||
h, w = frame.shape[:2]
|
||||
new_h, new_w = int(h / frac), int(w / frac) # shrink crop window
|
||||
y1 = (h - new_h) // 2 # center the crop
|
||||
x1 = (w - new_w) // 2
|
||||
cropped = frame[y1:y1 + new_h, x1:x1 + new_w]
|
||||
return np.array(Image.fromarray(cropped).resize((w, h), Image.LANCZOS))
|
||||
```
|
||||
|
||||
At `t=0`, `frac=1.0` so the crop is the full frame. At `t=duration`, `frac=1+zoom_ratio` so the crop is slightly smaller, and upscaling it back to full resolution creates the zoom effect. `zoom_ratio=0.08` means an 8% zoom over the full duration — perceptible but not distracting.
|
||||
|
||||
`apply_to=["mask"]` passes the same transform to the mask channel if one is present, keeping the mask geometrically in sync with the image.
|
||||
|
||||
---
|
||||
|
||||
## 7. Scene builders
|
||||
|
||||
Scene builders assemble the layers for a given `visual_cue` type into a `CompositeVideoClip`. Each builder follows the same pattern: build layers bottom-to-top, append to a list, return `CompositeVideoClip(layers, size=bg.size).set_duration(d)`.
|
||||
|
||||
The explicit `.set_duration(d)` on the return value is mandatory — see [§9.2](#92-the-set_duration-gotcha) for why.
|
||||
|
||||
### 7.1 Data scene — `build_data_scene`
|
||||
|
||||
Used for `visual_cue` values `bar_chart_comparison` and `line_trend`. The most information-dense layout:
|
||||
|
||||
- **Background**: full-canvas `ImageClip`, Ken Burns zoom at 8%, brightness reduced by 40 units via `vfx.lum_contrast(0, -40)`.
|
||||
- **Chart**: resized to 700px wide, centred horizontally, positioned 180px from the top. Fades in over 0.6s starting at `t=0.5` and fades out over 0.4s at the end.
|
||||
- **Insight card**: centred horizontally at y=820 (approximately the lower fifth of a 1080p frame). Fades in over 0.5s.
|
||||
- **Avatar**: circular-masked at 240px diameter, positioned 40px from the bottom-right corner (`bg.w - 280, bg.h - 280`).
|
||||
|
||||
### 7.2 Bullet scene — `build_bullet_scene`
|
||||
|
||||
Used for `visual_cue` value `bullet_points`. A simpler layout suited to lists of supporting facts:
|
||||
|
||||
- **Background**: Ken Burns at 5% zoom (slower than the data scene — more contemplative pacing), brightness reduced by 50 units.
|
||||
- **Bullet overlay**: rendered by `make_bullet_overlay`, centred both horizontally and vertically, fades in over 0.7s.
|
||||
- **Avatar**: circular-masked at 200px diameter (slightly smaller than in the data scene), positioned 40px from the bottom-right corner.
|
||||
|
||||
If `bullet_lines` is not provided by the caller, the builder falls back to using `insight.key_insight` and `insight.supporting_stat` as two bullet points.
|
||||
|
||||
### 7.3 Full avatar scene — `build_full_avatar_scene`
|
||||
|
||||
Used for `visual_cue` value `full_avatar`. The "Hook" scene — designed to open a piece with a direct-to-camera delivery that grabs attention before the data arrives. No overlays; the avatar fills the entire frame:
|
||||
|
||||
```python
|
||||
avatar = VideoFileClip(assets.avatar_video).subclip(0, d)
|
||||
return avatar.resize(height=1080).set_duration(d)
|
||||
```
|
||||
|
||||
This is the only scene type that does not use a `CompositeVideoClip` — it returns a `VideoFileClip` directly. The explicit `.set_duration(d)` is still applied (see §9.2).
|
||||
|
||||
---
|
||||
|
||||
## 8. Scene dispatcher — `dispatch_scene`
|
||||
|
||||
```python
|
||||
dispatch_scene(insight: Insight, assets: SceneAssets,
|
||||
bullet_lines: Optional[list[str]] = None) -> CompositeVideoClip
|
||||
```
|
||||
|
||||
The dispatcher is the JSON bridge's execution layer. It reads `insight.visual_cue` and routes to the correct builder, generating any intermediate assets (charts) along the way:
|
||||
|
||||
```
|
||||
visual_cue value Action
|
||||
─────────────────────────────────────────────────────
|
||||
"full_avatar" → build_full_avatar_scene()
|
||||
"bar_chart_comparison" → make_bar_chart() → build_data_scene()
|
||||
"line_trend" → make_line_trend() → build_data_scene()
|
||||
"bullet_points" → build_bullet_scene()
|
||||
<anything else> → build_data_scene() with no chart (fallback)
|
||||
```
|
||||
|
||||
Chart PNGs are written to `/tmp/chart.png`. This is intentionally a fixed path — each call overwrites the previous chart, which is fine because `dispatch_scene` is called sequentially per scene. If scenes are ever parallelised, use a `job_id`-prefixed temp path instead.
|
||||
|
||||
---
|
||||
|
||||
## 9. Crossfade transitions
|
||||
|
||||
### 9.1 How `crossfade_concat` works
|
||||
|
||||
```python
|
||||
def crossfade_concat(scenes: list, fade_dur: float = 0.5) -> CompositeVideoClip:
|
||||
faded = []
|
||||
for i, clip in enumerate(scenes):
|
||||
c = clip
|
||||
if i > 0:
|
||||
c = c.fx(vfx.crossfadein, fade_dur)
|
||||
faded.append(c)
|
||||
return concatenate_videoclips(faded, padding=-fade_dur, method="compose")
|
||||
```
|
||||
|
||||
`vfx.crossfadein` makes a clip's opacity ramp from 0 to 1 over `fade_dur` seconds from its start point. This handles the incoming side of the dissolve.
|
||||
|
||||
`padding=-fade_dur` is the critical parameter. By default, `concatenate_videoclips` places each clip immediately after the previous one ends. A negative padding shifts each clip left by `fade_dur` seconds, so it starts while the previous clip is still playing. The overlap window is exactly `fade_dur` seconds, which matches the duration of the `crossfadein` effect — this is what produces a dissolve rather than a hard cut or a gap.
|
||||
|
||||
`method="compose"` tells MoviePy to use `CompositeVideoClip` internally for the overlapping portions rather than trying to blend frames at the pixel level, which is how the alpha ramp from `crossfadein` is correctly respected.
|
||||
|
||||
The default `fade_dur` of `0.5s` is appropriate for fast-paced content. Increase to `0.8–1.0s` for a more cinematic feel. The total output duration is `sum(scene.duration for scene in scenes) - (len(scenes) - 1) * fade_dur`.
|
||||
|
||||
### 9.2 The `set_duration` gotcha
|
||||
|
||||
`CompositeVideoClip` infers its total duration by scanning the durations of all constituent clips. When sub-clips have `set_start` offsets — such as the chart clip which starts at `t=0.5` and has a duration of `d - 1.5`, and the insight card which starts at `t=0.5` with a duration of `d - 1.0` — MoviePy computes the composite's duration as `max(clip.start + clip.duration for clip in layers)`.
|
||||
|
||||
In most cases this yields a value slightly larger than `d` due to floating-point arithmetic on the offset calculations, or occasionally slightly smaller if a sub-clip ends fractionally before the background. Either error causes `crossfade_concat`'s `padding=-fade_dur` overlap to be miscalculated, typically producing a black flash frame at each scene boundary.
|
||||
|
||||
The fix is to explicitly call `.set_duration(d)` on every scene builder's return value, overriding the inferred value with the authoritative duration from the `Insight`:
|
||||
|
||||
```python
|
||||
return CompositeVideoClip(layers, size=bg.size).set_duration(d)
|
||||
```
|
||||
|
||||
This must be applied to all three builders, including `build_full_avatar_scene`, because a `resize()` call on a `VideoFileClip` creates a new clip object whose duration is re-derived from the source — it does not inherit the `subclip(0, d)` duration reliably on all platforms.
|
||||
|
||||
---
|
||||
|
||||
## 10. Master compositor — `compose_video`
|
||||
|
||||
```python
|
||||
def compose_video(scenes: list, output_path: str = "output.mp4",
|
||||
fps: int = 24, fade_dur: float = 0.5) -> str
|
||||
```
|
||||
|
||||
The final assembly step. Calls `crossfade_concat` to produce the dissolved timeline, then writes to an H.264 MP4 via MoviePy's `write_videofile`:
|
||||
|
||||
```python
|
||||
final.write_videofile(
|
||||
output_path,
|
||||
fps=fps,
|
||||
codec="libx264",
|
||||
audio_codec="aac",
|
||||
threads=4,
|
||||
preset="fast",
|
||||
logger=None,
|
||||
)
|
||||
```
|
||||
|
||||
`preset="fast"` is a reasonable default for a production pipeline — it is significantly faster than `slow` or `medium` with only a marginal quality difference at typical web streaming bitrates. Change to `slow` for archive-quality output. `logger=None` suppresses the verbose ffmpeg progress output; remove it during debugging.
|
||||
|
||||
`threads=4` maps to ffmpeg's `-threads` flag. Increase if the host has more cores available. This affects the encoding step only — MoviePy's frame rendering is single-threaded.
|
||||
|
||||
---
|
||||
|
||||
## 11. FastAPI server
|
||||
|
||||
`api_server.py` wraps the composition pipeline behind an HTTP API, enabling it to be called from any frontend, automation script, or orchestration system.
|
||||
|
||||
### 11.1 Request models
|
||||
|
||||
**`InsightPayload`** — mirrors the `Insight` dataclass with Pydantic validation:
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| `key_insight` | str | required | Headline text |
|
||||
| `supporting_stat` | str | required | Sub-headline text |
|
||||
| `visual_cue` | str | required | Scene template selector |
|
||||
| `audio_tone` | str | required | Downstream audio metadata |
|
||||
| `duration` | float | 3.0–60.0 | Scene length in seconds |
|
||||
| `chart_data` | dict | optional | Data payload for chart generators |
|
||||
| `bullet_lines` | list[str] | optional | Explicit bullet text (overrides defaults) |
|
||||
|
||||
**`ComposeRequest`** — the top-level request body:
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `insights` | list[InsightPayload] | required | Ordered list of scenes |
|
||||
| `fps` | int | 24 | Output frame rate (12–60) |
|
||||
| `fade_dur` | float | 0.5 | Crossfade duration in seconds (0.0–2.0) |
|
||||
|
||||
**`JobStatus`** — the response model for job tracking:
|
||||
|
||||
| Field | Values | Description |
|
||||
|-------|--------|-------------|
|
||||
| `job_id` | UUID hex string | Unique identifier for polling |
|
||||
| `status` | `queued`, `processing`, `done`, `error` | Current state |
|
||||
| `output_url` | `/download/{job_id}` or null | Available when `status == "done"` |
|
||||
| `error` | string or null | Error message when `status == "error"` |
|
||||
|
||||
### 11.2 Job lifecycle
|
||||
|
||||
Video composition is CPU-intensive and typically takes 30–120 seconds for a multi-scene piece. The API uses FastAPI's `BackgroundTasks` to run composition asynchronously so the HTTP response is immediate:
|
||||
|
||||
```
|
||||
POST /compose
|
||||
│
|
||||
├─ Validates payload, saves uploaded files to /tmp/broll_jobs/{job_id}/
|
||||
├─ Creates JobStatus(status="queued")
|
||||
├─ Registers BackgroundTask → _compose_worker()
|
||||
└─ Returns 202 Accepted with job_id
|
||||
|
||||
_compose_worker() (background)
|
||||
│
|
||||
├─ Sets status = "processing"
|
||||
├─ Runs _sync_compose() in a thread pool (loop.run_in_executor)
|
||||
│ └─ Iterates insights → dispatch_scene() → compose_video()
|
||||
├─ On success: status = "done", output_url = "/download/{job_id}"
|
||||
└─ On error: status = "error", error = str(exc)
|
||||
|
||||
GET /status/{job_id} ← poll until status == "done" or "error"
|
||||
|
||||
GET /download/{job_id} ← returns MP4 file
|
||||
```
|
||||
|
||||
`loop.run_in_executor(None, _sync_compose)` is important: MoviePy's frame rendering and ffmpeg's encoding are blocking operations. Running them directly in an `async` function would block the entire event loop. `run_in_executor` offloads the work to a thread pool, keeping the server responsive to other requests during composition.
|
||||
|
||||
The job store is currently a plain Python dict (`_jobs`). This is appropriate for a single-worker development server. Replace with Redis (using `aioredis` or `redis-py`) for multi-worker or multi-instance deployments.
|
||||
|
||||
### 11.3 API endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/compose` | Start a composition job (multipart form) |
|
||||
| `GET` | `/status/{job_id}` | Poll job status |
|
||||
| `GET` | `/download/{job_id}` | Download finished MP4 |
|
||||
| `POST` | `/preview/chart` | Generate and return a chart PNG (no video) |
|
||||
| `GET` | `/health` | Liveness check |
|
||||
|
||||
Interactive documentation is available at `http://localhost:8000/docs` once the server is running (FastAPI's built-in Swagger UI).
|
||||
|
||||
---
|
||||
|
||||
## 12. Running the project
|
||||
|
||||
### 12.1 Smoke test (no media files needed)
|
||||
|
||||
The smoke test validates all asset generators — chart PNGs, bullet overlays, and insight cards — without requiring any background images or avatar videos:
|
||||
|
||||
```bash
|
||||
python broll_composer.py
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
Chart saved → /tmp/demo_chart.png
|
||||
Bullets saved → /tmp/demo_bullets.png
|
||||
Insight card saved → /tmp/demo_card.png
|
||||
|
||||
Sample Insight JSON: { ... }
|
||||
|
||||
All asset generation tests passed.
|
||||
To run full video composition, supply real background_img and avatar_video paths.
|
||||
```
|
||||
|
||||
Inspect the PNG files in `/tmp/` to visually verify chart rendering before running the full pipeline.
|
||||
|
||||
### 12.2 Full video composition
|
||||
|
||||
```python
|
||||
from broll_composer import pipeline_from_json
|
||||
|
||||
insight_json = """{
|
||||
"key_insight": "AI reduced production time by 40%",
|
||||
"supporting_stat": "HubSpot 2026: 12% CTR lift",
|
||||
"visual_cue": "bar_chart_comparison",
|
||||
"audio_tone": "authoritative_and_surprising",
|
||||
"duration": 10.0,
|
||||
"chart_data": {
|
||||
"labels": ["Content Velocity", "CTR", "Engagement", "Cost/Lead"],
|
||||
"before": [30, 22, 18, 60],
|
||||
"after": [72, 34, 41, 38]
|
||||
}
|
||||
}"""
|
||||
|
||||
output_path = pipeline_from_json(
|
||||
insight_json,
|
||||
background_img="path/to/background.jpg",
|
||||
avatar_video="path/to/avatar.mp4", # optional
|
||||
)
|
||||
print(f"Video written to {output_path}")
|
||||
```
|
||||
|
||||
### 12.3 API server
|
||||
|
||||
```bash
|
||||
uvicorn api_server:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
For development with auto-reload:
|
||||
|
||||
```bash
|
||||
uvicorn api_server:app --reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Calling the API
|
||||
|
||||
The `/compose` endpoint accepts `multipart/form-data` with three parts: `payload` (JSON string), `background` (image file), and optionally `avatar` (video file).
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/compose \
|
||||
-F 'payload={
|
||||
"insights": [{
|
||||
"key_insight": "AI reduced production time by 40%",
|
||||
"supporting_stat": "HubSpot 2026: 12% CTR lift",
|
||||
"visual_cue": "bar_chart_comparison",
|
||||
"audio_tone": "authoritative_and_surprising",
|
||||
"duration": 10.0,
|
||||
"chart_data": {
|
||||
"labels": ["Velocity","CTR","Engagement","Cost/Lead"],
|
||||
"before": [30, 22, 18, 60],
|
||||
"after": [72, 34, 41, 38]
|
||||
}
|
||||
}],
|
||||
"fps": 24,
|
||||
"fade_dur": 0.5
|
||||
}' \
|
||||
-F 'background=@./bg.jpg' \
|
||||
-F 'avatar=@./avatar.mp4'
|
||||
```
|
||||
|
||||
This returns a `JobStatus` with a `job_id`. Poll for completion:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/status/{job_id}
|
||||
# → {"job_id": "...", "status": "done", "output_url": "/download/..."}
|
||||
```
|
||||
|
||||
Download the finished video:
|
||||
|
||||
```bash
|
||||
curl -O http://localhost:8000/download/{job_id}
|
||||
```
|
||||
|
||||
Preview a chart without video assembly:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/preview/chart?title=My+Chart&chart_type=bar_chart_comparison" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"labels":["A","B"],"before":[30,22],"after":[72,34]}' \
|
||||
--output preview.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Production notes
|
||||
|
||||
**Concurrency**: FastAPI's `BackgroundTasks` runs in the same process as the web server. Under concurrent load, multiple composition jobs will share the same thread pool, which can cause memory pressure (each MoviePy frame rendering buffers several seconds of uncompressed video). For production, move composition to a dedicated worker queue (Celery + Redis, or ARQ) and have the API server dispatch jobs to it rather than running them in-process.
|
||||
|
||||
**Temp file isolation**: Chart PNGs and insight card PNGs are written to fixed paths under `/tmp/`. This is safe for sequential processing but will cause race conditions if jobs are parallelised. Prefix all temp file paths with the `job_id` to isolate them:
|
||||
|
||||
```python
|
||||
chart_path = f"/tmp/{job_id}_chart.png"
|
||||
```
|
||||
|
||||
**Memory**: MoviePy loads entire video clips into memory for compositing. For scenes longer than ~30 seconds with a high-resolution avatar, memory use can reach several GB. If this is a concern, render scenes individually and use ffmpeg's `concat` demuxer to join them in a second pass rather than compositing them all in Python.
|
||||
|
||||
**ffmpeg version**: MoviePy 1.0.3 delegates encoding to ffmpeg. Versions prior to 4.x may not support all `preset` values or the `aac` codec without additional flags. The pipeline is tested against ffmpeg 5.x and 6.x.
|
||||
|
||||
**File cleanup**: Completed job files accumulate in `/tmp/broll_jobs/`. Add a cleanup background task or cron job that deletes job directories older than a configurable TTL (e.g. 1 hour).
|
||||
|
||||
---
|
||||
|
||||
## 15. Extending the pipeline
|
||||
|
||||
**Adding a new scene template**: add a builder function following the `build_*_scene` naming convention, then add a `visual_cue` string → function mapping in `dispatch_scene`. No other changes are needed.
|
||||
|
||||
**Adding a new chart type**: add a `make_*` function that writes a transparent PNG, then handle the new `visual_cue` in `dispatch_scene` by calling it before passing `assets` to a builder.
|
||||
|
||||
**Supporting multiple backgrounds per script**: `SceneAssets` currently takes a single `background_img`. To vary the background per scene, add a `background_img` field to `InsightPayload` in the API model and pass it through to `SceneAssets` in the compose worker.
|
||||
|
||||
**Audio**: the pipeline produces silent video. Attach a voiceover by loading it as a MoviePy `AudioFileClip`, setting its start time to align with each scene, and passing the composite audio to `final.set_audio()` before calling `write_videofile`.
|
||||
@@ -1,229 +0,0 @@
|
||||
"""
|
||||
FastAPI wrapper for the B-Roll Composer pipeline.
|
||||
POST /compose → triggers scene assembly, returns video download URL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from broll_composer import (
|
||||
Insight, SceneAssets, dispatch_scene, compose_video,
|
||||
make_bar_chart, make_line_trend, make_bullet_overlay,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(
|
||||
title="B-Roll Composer API",
|
||||
description="Programmatic video composition: Background + Chart + Avatar Circle",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
WORK_DIR = Path("/tmp/broll_jobs")
|
||||
WORK_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class InsightPayload(BaseModel):
|
||||
key_insight: str = Field(..., example="AI tools reduced content cycles by 40% in 2025.")
|
||||
supporting_stat: str = Field(..., example="HubSpot 2026 report cites a 12% lift in CTR.")
|
||||
visual_cue: str = Field(
|
||||
...,
|
||||
example="bar_chart_comparison",
|
||||
description="bar_chart_comparison | line_trend | bullet_points | full_avatar",
|
||||
)
|
||||
audio_tone: str = Field(..., example="authoritative_and_surprising")
|
||||
duration: float = Field(default=10.0, ge=3.0, le=60.0)
|
||||
chart_data: dict = Field(default_factory=dict)
|
||||
bullet_lines: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ComposeRequest(BaseModel):
|
||||
insights: List[InsightPayload]
|
||||
fps: int = Field(default=24, ge=12, le=60)
|
||||
fade_dur: float = Field(default=0.5, ge=0.0, le=2.0,
|
||||
description="Crossfade duration in seconds between scenes")
|
||||
|
||||
|
||||
class JobStatus(BaseModel):
|
||||
job_id: str
|
||||
status: str # queued | processing | done | error
|
||||
output_url: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory job store (replace with Redis in production)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_jobs: dict[str, JobStatus] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background task: composition worker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _compose_worker(
|
||||
job_id: str,
|
||||
request: ComposeRequest,
|
||||
bg_path: str,
|
||||
avatar_path: Optional[str],
|
||||
):
|
||||
job = _jobs[job_id]
|
||||
job.status = "processing"
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
out_path = str(WORK_DIR / f"{job_id}.mp4")
|
||||
|
||||
def _sync_compose():
|
||||
scenes = []
|
||||
for i, payload in enumerate(request.insights):
|
||||
insight = Insight(
|
||||
key_insight=payload.key_insight,
|
||||
supporting_stat=payload.supporting_stat,
|
||||
visual_cue=payload.visual_cue,
|
||||
audio_tone=payload.audio_tone,
|
||||
chart_data=payload.chart_data,
|
||||
duration=payload.duration,
|
||||
)
|
||||
assets = SceneAssets(
|
||||
background_img=bg_path,
|
||||
avatar_video=avatar_path,
|
||||
)
|
||||
scene = dispatch_scene(insight, assets, payload.bullet_lines)
|
||||
scenes.append(scene)
|
||||
|
||||
compose_video(scenes, output_path=out_path, fps=request.fps,
|
||||
fade_dur=request.fade_dur)
|
||||
return out_path
|
||||
|
||||
await loop.run_in_executor(None, _sync_compose)
|
||||
job.status = "done"
|
||||
job.output_url = f"/download/{job_id}"
|
||||
|
||||
except Exception as exc:
|
||||
job.status = "error"
|
||||
job.error = str(exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/compose", response_model=JobStatus, status_code=202)
|
||||
async def start_compose(
|
||||
background_tasks: BackgroundTasks,
|
||||
payload: str = Form(..., description="JSON string matching ComposeRequest schema"),
|
||||
background: UploadFile = File(..., description="Background image (JPEG/PNG)"),
|
||||
avatar: Optional[UploadFile] = File(None, description="Avatar video (MP4) — optional"),
|
||||
):
|
||||
"""
|
||||
Kick off a video composition job.
|
||||
- **payload**: JSON body (ComposeRequest)
|
||||
- **background**: background image file
|
||||
- **avatar**: optional avatar video file
|
||||
Returns a job_id — poll GET /status/{job_id} for progress.
|
||||
"""
|
||||
try:
|
||||
request = ComposeRequest(**json.loads(payload))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid payload: {e}")
|
||||
|
||||
job_id = uuid.uuid4().hex
|
||||
|
||||
# Save uploads
|
||||
job_dir = WORK_DIR / job_id
|
||||
job_dir.mkdir(exist_ok=True)
|
||||
|
||||
bg_path = str(job_dir / background.filename)
|
||||
with open(bg_path, "wb") as f:
|
||||
f.write(await background.read())
|
||||
|
||||
avatar_path = None
|
||||
if avatar:
|
||||
avatar_path = str(job_dir / avatar.filename)
|
||||
with open(avatar_path, "wb") as f:
|
||||
f.write(await avatar.read())
|
||||
|
||||
# Register job
|
||||
job = JobStatus(job_id=job_id, status="queued")
|
||||
_jobs[job_id] = job
|
||||
|
||||
# Launch background worker
|
||||
background_tasks.add_task(
|
||||
_compose_worker, job_id, request, bg_path, avatar_path
|
||||
)
|
||||
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/status/{job_id}", response_model=JobStatus)
|
||||
async def get_status(job_id: str):
|
||||
"""Poll composition job status."""
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/download/{job_id}")
|
||||
async def download_video(job_id: str):
|
||||
"""Download the finished video."""
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if job.status != "done":
|
||||
raise HTTPException(status_code=409, detail=f"Job status: {job.status}")
|
||||
|
||||
out_path = WORK_DIR / f"{job_id}.mp4"
|
||||
if not out_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Output file missing")
|
||||
|
||||
return FileResponse(
|
||||
path=str(out_path),
|
||||
media_type="video/mp4",
|
||||
filename=f"broll_{job_id}.mp4",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/preview/chart")
|
||||
async def preview_chart(
|
||||
chart_data: dict,
|
||||
title: str = "",
|
||||
chart_type: str = "bar_chart_comparison",
|
||||
):
|
||||
"""Generate and return a chart PNG for preview (no video assembly)."""
|
||||
out = str(WORK_DIR / f"preview_{uuid.uuid4().hex}.png")
|
||||
if chart_type == "bar_chart_comparison":
|
||||
make_bar_chart(chart_data, out, title)
|
||||
else:
|
||||
make_line_trend(chart_data, out, title)
|
||||
return FileResponse(path=out, media_type="image/png")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
@@ -1,456 +0,0 @@
|
||||
"""
|
||||
Programmatic B-Roll Composer
|
||||
Layered composition pipeline: Background + Chart + Avatar Circle + Text Overlays
|
||||
"""
|
||||
|
||||
import json
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.patches as mpatches
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from moviepy.editor import (
|
||||
VideoFileClip, ImageClip, CompositeVideoClip,
|
||||
TextClip, ColorClip, concatenate_videoclips,
|
||||
)
|
||||
import moviepy.video.fx.all as vfx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Crossfade concat (Option 1: crossfadein + negative padding)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def crossfade_concat(scenes: list, fade_dur: float = 0.5) -> CompositeVideoClip:
|
||||
"""
|
||||
Concatenate scenes with a dissolve transition between each pair.
|
||||
|
||||
Each clip (except the first) gets a crossfadein effect.
|
||||
padding=-fade_dur overlaps consecutive clips so the fade actually fires
|
||||
instead of creating a black gap. set_duration on every scene is
|
||||
mandatory — CompositeVideoClip.duration can be ambiguous without it,
|
||||
which makes the overlap math wrong.
|
||||
"""
|
||||
faded = []
|
||||
for i, clip in enumerate(scenes):
|
||||
c = clip
|
||||
if i > 0:
|
||||
c = c.fx(vfx.crossfadein, fade_dur)
|
||||
faded.append(c)
|
||||
return concatenate_videoclips(faded, padding=-fade_dur, method="compose")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data structures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Insight:
|
||||
key_insight: str
|
||||
supporting_stat: str
|
||||
visual_cue: str # bar_chart_comparison | line_trend | bullet_points | full_avatar
|
||||
audio_tone: str
|
||||
chart_data: dict = field(default_factory=dict)
|
||||
duration: float = 10.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SceneAssets:
|
||||
background_img: str
|
||||
chart_img: Optional[str] = None
|
||||
avatar_video: Optional[str] = None
|
||||
bullet_img: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chart generator (Matplotlib → PNG with transparency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHART_STYLE = {
|
||||
"bg": "#0D0D0D",
|
||||
"bar_before": "#2E4057",
|
||||
"bar_after": "#E63946",
|
||||
"text": "#F1F1EF",
|
||||
"grid": "#2A2A2A",
|
||||
"accent": "#E63946",
|
||||
}
|
||||
|
||||
|
||||
def make_bar_chart(data: dict, out_path: str, title: str = "") -> str:
|
||||
"""Render a side-by-side comparison bar chart. Returns output path."""
|
||||
labels = data.get("labels", [])
|
||||
before = data.get("before", [])
|
||||
after = data.get("after", [])
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||
ax.set_facecolor("none")
|
||||
|
||||
x = np.arange(len(labels))
|
||||
w = 0.35
|
||||
bars_b = ax.bar(x - w / 2, before, w, color=CHART_STYLE["bar_before"],
|
||||
label="Before", zorder=3, edgecolor="none")
|
||||
bars_a = ax.bar(x + w / 2, after, w, color=CHART_STYLE["bar_after"],
|
||||
label="After", zorder=3, edgecolor="none")
|
||||
|
||||
ax.set_xticks(x)
|
||||
ax.set_xticklabels(labels, color=CHART_STYLE["text"], fontsize=11)
|
||||
ax.tick_params(axis="y", colors=CHART_STYLE["text"])
|
||||
ax.spines[:].set_visible(False)
|
||||
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
|
||||
ax.set_axisbelow(True)
|
||||
|
||||
# Value labels on bars
|
||||
for bar in [*bars_b, *bars_a]:
|
||||
h = bar.get_height()
|
||||
ax.text(bar.get_x() + bar.get_width() / 2, h + 0.5, f"{h:.0f}%",
|
||||
ha="center", va="bottom", color=CHART_STYLE["text"], fontsize=9,
|
||||
fontweight="bold")
|
||||
|
||||
legend = ax.legend(frameon=False, labelcolor=CHART_STYLE["text"],
|
||||
fontsize=10, loc="upper left")
|
||||
if title:
|
||||
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||
fontweight="bold", pad=12)
|
||||
|
||||
fig.tight_layout(pad=0.5)
|
||||
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
return out_path
|
||||
|
||||
|
||||
def make_line_trend(data: dict, out_path: str, title: str = "") -> str:
|
||||
"""Render a trend line chart. Returns output path."""
|
||||
x_vals = data.get("x", [])
|
||||
y_vals = data.get("y", [])
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||
ax.set_facecolor("none")
|
||||
ax.plot(x_vals, y_vals, color=CHART_STYLE["accent"],
|
||||
linewidth=2.5, marker="o", markersize=7, zorder=3)
|
||||
ax.fill_between(x_vals, y_vals, alpha=0.12, color=CHART_STYLE["accent"])
|
||||
ax.spines[:].set_visible(False)
|
||||
ax.tick_params(colors=CHART_STYLE["text"])
|
||||
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
|
||||
if title:
|
||||
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||
fontweight="bold", pad=12)
|
||||
fig.tight_layout(pad=0.5)
|
||||
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
return out_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text / Bullet overlay (Pillow → PNG)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_bullet_overlay(lines: list[str], out_path: str,
|
||||
width: int = 900, font_size: int = 32) -> str:
|
||||
"""Render bullet points on a semi-transparent dark pill. Returns path."""
|
||||
padding = 32
|
||||
line_h = font_size + 16
|
||||
img_h = padding * 2 + len(lines) * line_h + 12
|
||||
img = Image.new("RGBA", (width, img_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Semi-transparent background pill
|
||||
draw.rounded_rectangle([0, 0, width - 1, img_h - 1],
|
||||
radius=18, fill=(10, 10, 10, 185))
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
font_size)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
y = padding
|
||||
for line in lines:
|
||||
draw.text((padding + 18, y), f"• {line}", font=font, fill=(241, 241, 239, 255))
|
||||
y += line_h
|
||||
|
||||
img.save(out_path, format="PNG")
|
||||
return out_path
|
||||
|
||||
|
||||
def make_insight_card(insight: str, stat: str, out_path: str,
|
||||
width: int = 960, height: int = 200) -> str:
|
||||
"""Render a bold insight card (headline + supporting stat). Returns path."""
|
||||
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rounded_rectangle([0, 0, width - 1, height - 1],
|
||||
radius=14, fill=(10, 10, 10, 200))
|
||||
|
||||
# Red accent bar
|
||||
draw.rectangle([28, 24, 36, height - 24], fill=(230, 57, 70, 255))
|
||||
|
||||
try:
|
||||
font_lg = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
|
||||
font_sm = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
|
||||
except OSError:
|
||||
font_lg = font_sm = ImageFont.load_default()
|
||||
|
||||
draw.text((58, 36), insight, font=font_lg, fill=(241, 241, 239, 255))
|
||||
draw.text((58, 90), stat, font=font_sm, fill=(180, 180, 178, 230))
|
||||
|
||||
img.save(out_path, format="PNG")
|
||||
return out_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Circular avatar mask
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply_circle_mask(clip: VideoFileClip, diameter: int) -> VideoFileClip:
|
||||
"""Resize clip and apply a circular alpha mask."""
|
||||
clip = clip.resize(height=diameter)
|
||||
w, h = clip.size
|
||||
|
||||
# Build a circular mask array (1 = opaque, 0 = transparent)
|
||||
Y, X = np.ogrid[:h, :w]
|
||||
cx, cy = w / 2, h / 2
|
||||
mask_arr = ((X - cx) ** 2 + (Y - cy) ** 2 <= (min(w, h) / 2) ** 2).astype(float)
|
||||
|
||||
mask_clip = ImageClip(mask_arr, ismask=True).set_duration(clip.duration)
|
||||
return clip.set_mask(mask_clip)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ken Burns zoom effect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ken_burns(clip: ImageClip, zoom_ratio: float = 0.08) -> ImageClip:
|
||||
"""Apply a slow zoom-in over the clip duration."""
|
||||
def zoom_frame(get_frame, t):
|
||||
frame = get_frame(t)
|
||||
frac = 1 + zoom_ratio * (t / clip.duration)
|
||||
h, w = frame.shape[:2]
|
||||
new_h, new_w = int(h / frac), int(w / frac)
|
||||
y1 = (h - new_h) // 2
|
||||
x1 = (w - new_w) // 2
|
||||
cropped = frame[y1:y1 + new_h, x1:x1 + new_w]
|
||||
return np.array(Image.fromarray(cropped).resize((w, h), Image.LANCZOS))
|
||||
|
||||
return clip.fl(zoom_frame, apply_to=["mask"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scene builders (one per visual_cue type)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_data_scene(assets: SceneAssets, insight: Insight) -> CompositeVideoClip:
|
||||
"""
|
||||
Layout: Background (Ken Burns) + Chart (fade-in) + Avatar circle (corner) + Insight card
|
||||
"""
|
||||
d = insight.duration
|
||||
layers = []
|
||||
|
||||
# 1. Background
|
||||
bg = (ImageClip(assets.background_img)
|
||||
.set_duration(d)
|
||||
.resize(height=1080))
|
||||
bg = ken_burns(bg)
|
||||
bg = bg.fx(vfx.lum_contrast, 0, -40) # darken 40 units
|
||||
layers.append(bg)
|
||||
|
||||
# 2. Programmatic chart
|
||||
if assets.chart_img:
|
||||
chart = (ImageClip(assets.chart_img)
|
||||
.set_duration(d - 1.5)
|
||||
.set_start(0.5)
|
||||
.resize(width=700)
|
||||
.set_position(("center", 180))
|
||||
.fx(vfx.fadein, 0.6)
|
||||
.fx(vfx.fadeout, 0.4))
|
||||
layers.append(chart)
|
||||
|
||||
# 3. Insight card at bottom
|
||||
card_path = "/tmp/insight_card.png"
|
||||
make_insight_card(insight.key_insight, insight.supporting_stat, card_path)
|
||||
card = (ImageClip(card_path)
|
||||
.set_duration(d - 1)
|
||||
.set_start(0.5)
|
||||
.set_position(("center", 820))
|
||||
.fx(vfx.fadein, 0.5))
|
||||
layers.append(card)
|
||||
|
||||
# 4. Avatar circle (bottom-right corner)
|
||||
if assets.avatar_video:
|
||||
avatar_raw = VideoFileClip(assets.avatar_video).subclip(0, d)
|
||||
avatar = apply_circle_mask(avatar_raw, diameter=240)
|
||||
avatar = avatar.set_position((bg.w - 280, bg.h - 280))
|
||||
layers.append(avatar)
|
||||
|
||||
# set_duration is required: CompositeVideoClip infers duration from its
|
||||
# constituent clips, which can be ambiguous when sub-clips have set_start
|
||||
# offsets. Without this, crossfade_concat's overlap math goes wrong.
|
||||
return CompositeVideoClip(layers, size=bg.size).set_duration(d)
|
||||
|
||||
|
||||
def build_bullet_scene(assets: SceneAssets, insight: Insight,
|
||||
bullets: list[str]) -> CompositeVideoClip:
|
||||
"""
|
||||
Layout: AI image (Ken Burns) + Bullet overlay + Avatar circle
|
||||
"""
|
||||
d = insight.duration
|
||||
layers = []
|
||||
|
||||
bg = (ImageClip(assets.background_img)
|
||||
.set_duration(d)
|
||||
.resize(height=1080))
|
||||
bg = ken_burns(bg, zoom_ratio=0.05)
|
||||
bg = bg.fx(vfx.lum_contrast, 0, -50)
|
||||
layers.append(bg)
|
||||
|
||||
bullet_path = "/tmp/bullets.png"
|
||||
make_bullet_overlay(bullets, bullet_path, width=860)
|
||||
bullets_clip = (ImageClip(bullet_path)
|
||||
.set_duration(d - 1)
|
||||
.set_start(0.5)
|
||||
.set_position(("center", "center"))
|
||||
.fx(vfx.fadein, 0.7))
|
||||
layers.append(bullets_clip)
|
||||
|
||||
if assets.avatar_video:
|
||||
avatar_raw = VideoFileClip(assets.avatar_video).subclip(0, d)
|
||||
avatar = apply_circle_mask(avatar_raw, diameter=200)
|
||||
avatar = avatar.set_position((bg.w - 240, bg.h - 240))
|
||||
layers.append(avatar)
|
||||
|
||||
return CompositeVideoClip(layers, size=bg.size).set_duration(d)
|
||||
|
||||
|
||||
def build_full_avatar_scene(assets: SceneAssets, insight: Insight) -> VideoFileClip:
|
||||
"""Full-screen avatar — the expensive 'Hook' scene. No overlay."""
|
||||
d = insight.duration
|
||||
avatar = VideoFileClip(assets.avatar_video).subclip(0, d)
|
||||
return avatar.resize(height=1080).set_duration(d)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scene dispatcher — maps visual_cue → builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def dispatch_scene(insight: Insight, assets: SceneAssets,
|
||||
bullet_lines: Optional[list[str]] = None) -> CompositeVideoClip:
|
||||
cue = insight.visual_cue
|
||||
|
||||
if cue == "full_avatar":
|
||||
return build_full_avatar_scene(assets, insight)
|
||||
|
||||
elif cue in ("bar_chart_comparison", "line_trend"):
|
||||
chart_path = "/tmp/chart.png"
|
||||
if cue == "bar_chart_comparison":
|
||||
make_bar_chart(insight.chart_data, chart_path,
|
||||
title=insight.key_insight)
|
||||
else:
|
||||
make_line_trend(insight.chart_data, chart_path,
|
||||
title=insight.key_insight)
|
||||
assets.chart_img = chart_path
|
||||
return build_data_scene(assets, insight)
|
||||
|
||||
elif cue == "bullet_points":
|
||||
lines = bullet_lines or [insight.key_insight, insight.supporting_stat]
|
||||
return build_bullet_scene(assets, insight, lines)
|
||||
|
||||
else:
|
||||
# Fallback: data scene without chart
|
||||
return build_data_scene(assets, insight)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Master compositor — assembles all scenes into one video
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compose_video(scenes: list, output_path: str = "output.mp4",
|
||||
fps: int = 24, fade_dur: float = 0.5) -> str:
|
||||
"""Concatenate scenes with crossfade transitions and write final video file."""
|
||||
final = crossfade_concat(scenes, fade_dur=fade_dur)
|
||||
final.write_videofile(
|
||||
output_path,
|
||||
fps=fps,
|
||||
codec="libx264",
|
||||
audio_codec="aac",
|
||||
threads=4,
|
||||
preset="fast",
|
||||
logger=None,
|
||||
)
|
||||
return output_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON bridge — LLM insight → assets + scene
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pipeline_from_json(insight_json: str,
|
||||
background_img: str,
|
||||
avatar_video: Optional[str] = None) -> str:
|
||||
"""
|
||||
Full pipeline:
|
||||
1. Parse LLM insight JSON
|
||||
2. Generate chart / overlay assets
|
||||
3. Build scene
|
||||
4. Write video
|
||||
Returns path to output video.
|
||||
"""
|
||||
data = json.loads(insight_json)
|
||||
insight = Insight(**{k: data[k] for k in Insight.__dataclass_fields__ if k in data})
|
||||
assets = SceneAssets(background_img=background_img, avatar_video=avatar_video)
|
||||
scene = dispatch_scene(insight, assets,
|
||||
bullet_lines=data.get("bullet_lines"))
|
||||
out = f"/tmp/scene_{insight.visual_cue}.mp4"
|
||||
compose_video([scene], output_path=out)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Demo / smoke-test (no real media files needed for chart generation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
# --- Test 1: Chart PNG generation only ---
|
||||
sample_bar_data = {
|
||||
"labels": ["Content Velocity", "CTR", "Engagement", "Cost/Lead"],
|
||||
"before": [30, 22, 18, 60],
|
||||
"after": [72, 34, 41, 38],
|
||||
}
|
||||
chart_out = make_bar_chart(
|
||||
sample_bar_data,
|
||||
"/tmp/demo_chart.png",
|
||||
title="AI Tools Impact: Before vs After (2025)",
|
||||
)
|
||||
print(f"Chart saved → {chart_out}")
|
||||
|
||||
# --- Test 2: Bullet overlay PNG ---
|
||||
bullets = [
|
||||
"AI reduced content cycles by 40% in 2025",
|
||||
"HubSpot: 12% lift in CTR with AI-assisted copy",
|
||||
"Video production cost down 3x with hybrid pipeline",
|
||||
]
|
||||
bullet_out = make_bullet_overlay(bullets, "/tmp/demo_bullets.png")
|
||||
print(f"Bullets saved → {bullet_out}")
|
||||
|
||||
# --- Test 3: Insight card PNG ---
|
||||
card_out = make_insight_card(
|
||||
"AI tools reduced content cycles by 40%",
|
||||
"HubSpot 2026 report — 12% lift in CTR",
|
||||
"/tmp/demo_card.png",
|
||||
)
|
||||
print(f"Insight card saved → {card_out}")
|
||||
|
||||
# --- Test 4: JSON bridge (chart only, no video files required) ---
|
||||
sample_json = json.dumps({
|
||||
"key_insight": "AI reduced production time by 40%",
|
||||
"supporting_stat": "HubSpot 2026: 12% CTR lift",
|
||||
"visual_cue": "bar_chart_comparison",
|
||||
"audio_tone": "authoritative_and_surprising",
|
||||
"duration": 8.0,
|
||||
"chart_data": sample_bar_data,
|
||||
})
|
||||
print("\nSample Insight JSON:\n", sample_json)
|
||||
print("\nAll asset generation tests passed.")
|
||||
print("To run full video composition, supply real background_img and avatar_video paths.")
|
||||
@@ -2,34 +2,26 @@
|
||||
Podcast API Constants
|
||||
|
||||
Centralized constants and directory configuration for podcast module.
|
||||
All workspace paths use utils.storage_paths for root resolution.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from loguru import logger
|
||||
from services.story_writer.audio_generation_service import StoryAudioGenerationService
|
||||
from utils.storage_paths import get_repo_root, sanitize_user_id as _sanitize_user_id
|
||||
|
||||
# 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"
|
||||
ROOT_DIR = get_repo_root()
|
||||
|
||||
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()
|
||||
|
||||
# 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")
|
||||
|
||||
|
||||
def _sanitize_user_id(user_id: str) -> str:
|
||||
return "".join(c for c in user_id if c.isalnum() or c in ("-", "_"))
|
||||
MediaType = Literal["audio", "image", "video", "chart"]
|
||||
|
||||
|
||||
def get_podcast_media_dir(
|
||||
@@ -38,21 +30,30 @@ 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).
|
||||
|
||||
Requires user_id for tenant isolation. Falls back to default workspace
|
||||
only if no user_id provided (for backward compat in development).
|
||||
Logs a warning in production when user_id is missing.
|
||||
"""
|
||||
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()
|
||||
|
||||
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 called without user_id for {media_type} — using default workspace. This should not happen in production.")
|
||||
resolved_dir = (
|
||||
ROOT_DIR / "workspace" / "workspace_alwrity" / "media" / media_subdir
|
||||
).resolve()
|
||||
|
||||
if ensure_exists:
|
||||
resolved_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -61,14 +62,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:
|
||||
|
||||
216
backend/api/podcast/cost_estimator.py
Normal file
216
backend/api/podcast/cost_estimator.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Podcast cost estimation helpers.
|
||||
|
||||
Builds user-facing podcast estimates from the subscription pricing catalog
|
||||
instead of hard-coded frontend heuristics.
|
||||
|
||||
Supports multiple models for each component:
|
||||
- Audio TTS: minimax/speech-02-hd (default), qwen3-tts, cosyvoice-tts
|
||||
- Voice Clone: qwen3, cosyvoice, minimax
|
||||
- Image: qwen-image (default), ideogram-v3-turbo
|
||||
- Video: wan-2.5 (default), kling-v2.5, infinitetalk
|
||||
- LLM: gemini-2.5-flash (default)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.subscription_models import APIProvider
|
||||
from services.subscription.pricing_service import PricingService
|
||||
|
||||
|
||||
def _round_money(value: float) -> float:
|
||||
return round(float(value), 4)
|
||||
|
||||
|
||||
def _load_pricing(
|
||||
pricing_service: PricingService,
|
||||
provider: APIProvider,
|
||||
preferred_model: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Load pricing for a provider and model, with fallback to default."""
|
||||
pricing = pricing_service.get_pricing_for_provider_model(provider, preferred_model)
|
||||
if pricing:
|
||||
return pricing
|
||||
# Fallback to provider default model row (if configured).
|
||||
return pricing_service.get_pricing_for_provider_model(provider, "default")
|
||||
|
||||
|
||||
# Default models used in podcast generation
|
||||
DEFAULT_MODELS = {
|
||||
"gemini": "gemini-2.5-flash",
|
||||
"exa": "exa-search",
|
||||
"audio_tts": "minimax/speech-02-hd",
|
||||
"voice_clone": "wavespeed-ai/qwen3-tts/voice-clone",
|
||||
"image": "qwen-image",
|
||||
"video": "wan-2.5",
|
||||
}
|
||||
|
||||
|
||||
def estimate_podcast_cost(
|
||||
*,
|
||||
db: Session,
|
||||
duration_minutes: int,
|
||||
speakers: int,
|
||||
query_count: int,
|
||||
include_avatar_phase: bool = True,
|
||||
# Optional model overrides
|
||||
gemini_model: str = "gemini-2.5-flash",
|
||||
audio_tts_model: str = "minimax/speech-02-hd",
|
||||
voice_clone_engine: str = "qwen3",
|
||||
image_model: str = "qwen-image",
|
||||
video_model: str = "wan-2.5",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Compute a backend estimate for podcast creation.
|
||||
|
||||
Supports customizable models for each component.
|
||||
Uses pricing_catalog for accurate cost calculation.
|
||||
"""
|
||||
pricing_service = PricingService(db)
|
||||
|
||||
# Load pricing for each component and model
|
||||
gemini_pricing = _load_pricing(pricing_service, APIProvider.GEMINI, gemini_model)
|
||||
exa_pricing = _load_pricing(pricing_service, APIProvider.EXA, "exa-search")
|
||||
|
||||
# Audio TTS pricing (minimax/speech-02-hd)
|
||||
audio_pricing = _load_pricing(pricing_service, APIProvider.AUDIO, audio_tts_model)
|
||||
|
||||
# Voice clone pricing (different engines)
|
||||
voice_clone_model = f"wavespeed-ai/{voice_clone_engine}-tts/voice-clone"
|
||||
voice_clone_pricing = _load_pricing(pricing_service, APIProvider.AUDIO, voice_clone_model)
|
||||
if not voice_clone_pricing:
|
||||
# Try alternate model names
|
||||
voice_clone_pricing = _load_pricing(pricing_service, APIProvider.AUDIO, f"{voice_clone_engine}/voice-clone")
|
||||
|
||||
# Image pricing (qwen-image or ideogram)
|
||||
image_pricing = _load_pricing(pricing_service, APIProvider.STABILITY, image_model)
|
||||
|
||||
# Video pricing (wan-2.5, kling, or infinitetalk)
|
||||
video_pricing = _load_pricing(pricing_service, APIProvider.VIDEO, video_model)
|
||||
|
||||
# Return None if critical pricing unavailable (fail fast)
|
||||
if not gemini_pricing:
|
||||
return None
|
||||
|
||||
# Configuration
|
||||
minutes = max(1, int(duration_minutes or 1))
|
||||
speaker_count = max(1, int(speakers or 1))
|
||||
research_queries = max(1, int(query_count or 1))
|
||||
|
||||
# Token usage assumptions per phase
|
||||
analysis_input_tokens = 1800
|
||||
analysis_output_tokens = 1000
|
||||
research_synthesis_input_tokens = 2200
|
||||
research_synthesis_output_tokens = 900
|
||||
script_input_tokens = max(1800, minutes * 300)
|
||||
script_output_tokens = max(2200, minutes * 700)
|
||||
|
||||
# TTS: ~900 chars per minute per speaker
|
||||
estimated_tts_tokens = max(900, minutes * 900 * speaker_count)
|
||||
|
||||
# Voice clone: 1 clone operation per speaker
|
||||
voice_clone_count = speaker_count
|
||||
|
||||
# ===== COST CALCULATIONS =====
|
||||
|
||||
# 1. Analysis phase (LLM)
|
||||
analysis_cost = (
|
||||
analysis_input_tokens * float(gemini_pricing.get("cost_per_input_token") or 0.0)
|
||||
+ analysis_output_tokens * float(gemini_pricing.get("cost_per_output_token") or 0.0)
|
||||
)
|
||||
|
||||
# 2. Research phase
|
||||
# 2a. LLM for research synthesis
|
||||
research_llm_cost = (
|
||||
research_synthesis_input_tokens * float(gemini_pricing.get("cost_per_input_token") or 0.0)
|
||||
+ research_synthesis_output_tokens * float(gemini_pricing.get("cost_per_output_token") or 0.0)
|
||||
)
|
||||
# 2b. Search API (Exa)
|
||||
research_search_cost = 0.0
|
||||
if exa_pricing:
|
||||
research_search_cost = research_queries * float(exa_pricing.get("cost_per_request") or 0.0)
|
||||
research_cost = research_search_cost + research_llm_cost
|
||||
|
||||
# 3. Script generation (LLM)
|
||||
script_cost = (
|
||||
script_input_tokens * float(gemini_pricing.get("cost_per_input_token") or 0.0)
|
||||
+ script_output_tokens * float(gemini_pricing.get("cost_per_output_token") or 0.0)
|
||||
)
|
||||
|
||||
# 4. Audio TTS
|
||||
tts_cost = 0.0
|
||||
if audio_pricing:
|
||||
tts_cost = estimated_tts_tokens * float(audio_pricing.get("cost_per_input_token") or 0.0)
|
||||
|
||||
# 5. Voice cloning (if needed)
|
||||
voice_clone_cost = 0.0
|
||||
if voice_clone_pricing:
|
||||
voice_clone_cost = voice_clone_count * (
|
||||
float(voice_clone_pricing.get("cost_per_request") or 0.0)
|
||||
+ estimated_tts_tokens * float(voice_clone_pricing.get("cost_per_input_token") or 0.0)
|
||||
)
|
||||
|
||||
# 6. Avatar image generation
|
||||
avatar_cost = 0.0
|
||||
if include_avatar_phase and image_pricing:
|
||||
image_unit = float(image_pricing.get("cost_per_image") or image_pricing.get("cost_per_request") or 0.0)
|
||||
avatar_cost = speaker_count * image_unit
|
||||
|
||||
# 7. Video rendering
|
||||
video_cost = 0.0
|
||||
if video_pricing:
|
||||
# Assume 1 video render per minute (upper bound)
|
||||
video_cost = minutes * float(video_pricing.get("cost_per_request") or 0.0)
|
||||
|
||||
# ===== TOTALS =====
|
||||
llm_total = analysis_cost + research_llm_cost + script_cost
|
||||
audio_total = tts_cost + voice_clone_cost
|
||||
media_total = avatar_cost + video_cost
|
||||
total = llm_total + research_search_cost + audio_total + media_total
|
||||
|
||||
return {
|
||||
# Cost breakdown
|
||||
"analysisCost": _round_money(analysis_cost),
|
||||
"researchCost": _round_money(research_cost),
|
||||
"researchSearchCost": _round_money(research_search_cost),
|
||||
"researchLlmCost": _round_money(research_llm_cost),
|
||||
"scriptCost": _round_money(script_cost),
|
||||
"ttsCost": _round_money(tts_cost),
|
||||
"voiceCloneCost": _round_money(voice_clone_cost),
|
||||
"avatarCost": _round_money(avatar_cost),
|
||||
"videoCost": _round_money(video_cost),
|
||||
"total": _round_money(total),
|
||||
# Totals by category
|
||||
"llmCost": _round_money(llm_total),
|
||||
"audioCost": _round_money(audio_total),
|
||||
"mediaCost": _round_money(media_total),
|
||||
# Currency
|
||||
"currency": "USD",
|
||||
"source": "pricing_catalog",
|
||||
# Models used for this estimate
|
||||
"models": {
|
||||
"llm": gemini_model,
|
||||
"research": "exa-search",
|
||||
"audio_tts": audio_tts_model,
|
||||
"voice_clone": voice_clone_model,
|
||||
"image": image_model,
|
||||
"video": video_model,
|
||||
},
|
||||
# Assumptions used
|
||||
"assumptions": {
|
||||
"analysis_input_tokens": analysis_input_tokens,
|
||||
"analysis_output_tokens": analysis_output_tokens,
|
||||
"research_synthesis_input_tokens": research_synthesis_input_tokens,
|
||||
"research_synthesis_output_tokens": research_synthesis_output_tokens,
|
||||
"script_input_tokens": script_input_tokens,
|
||||
"script_output_tokens": script_output_tokens,
|
||||
"estimated_tts_tokens": estimated_tts_tokens,
|
||||
"research_queries": research_queries,
|
||||
"voice_clone_count": voice_clone_count,
|
||||
"video_requests": minutes,
|
||||
"avatar_requests": speaker_count if include_avatar_phase else 0,
|
||||
},
|
||||
}
|
||||
@@ -4,8 +4,9 @@ Podcast Analysis Handlers
|
||||
Analysis endpoint for podcast ideas.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
import json
|
||||
import uuid
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -20,13 +21,21 @@ 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 ..prompts import get_enhance_topic_prompt, format_website_context
|
||||
from ..models import (
|
||||
PodcastAnalyzeRequest,
|
||||
PodcastAnalyzeResponse,
|
||||
PodcastEnhanceIdeaRequest,
|
||||
PodcastEnhanceIdeaResponse
|
||||
PodcastEnhanceIdeaResponse,
|
||||
ExtractUrlRequest,
|
||||
ExtractUrlResponse,
|
||||
WebsiteAnalysisRequest,
|
||||
WebsiteAnalysisResponse,
|
||||
PodcastPreEstimateRequest,
|
||||
PodcastPreEstimateResponse,
|
||||
)
|
||||
from ..cost_estimator import estimate_podcast_cost
|
||||
|
||||
# Check if running in podcast-only demo mode
|
||||
def _is_podcast_only_mode() -> bool:
|
||||
@@ -36,6 +45,74 @@ def _is_podcast_only_mode() -> bool:
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/pre-estimate", response_model=PodcastPreEstimateResponse)
|
||||
async def pre_estimate_cost(
|
||||
request: PodcastPreEstimateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Lightweight endpoint to estimate podcast creation cost before analysis.
|
||||
|
||||
Takes user configuration (duration, speakers, query_count, podcast_mode) and returns
|
||||
a cost estimate WITHOUT running full analysis.
|
||||
|
||||
Optional model overrides can be specified to estimate with different models.
|
||||
"""
|
||||
try:
|
||||
include_avatar_phase = request.podcast_mode != "audio_only"
|
||||
|
||||
estimate = estimate_podcast_cost(
|
||||
db=db,
|
||||
duration_minutes=request.duration,
|
||||
speakers=request.speakers,
|
||||
query_count=request.query_count,
|
||||
include_avatar_phase=include_avatar_phase,
|
||||
# Model overrides if provided
|
||||
gemini_model=request.gemini_model or "gemini-2.5-flash",
|
||||
audio_tts_model=request.audio_tts_model or "minimax/speech-02-hd",
|
||||
voice_clone_engine=request.voice_clone_engine or "qwen3",
|
||||
image_model=request.image_model or "qwen-image",
|
||||
video_model=request.video_model or "wan-2.5",
|
||||
)
|
||||
|
||||
# Debug: get pricing row count and providers
|
||||
from models.subscription_models import APIProviderPricing
|
||||
pricing_count = db.query(APIProviderPricing).count()
|
||||
providers = db.query(APIProviderPricing.provider).distinct().all()
|
||||
provider_list = sorted([p[0].value for p in providers]) if providers else []
|
||||
|
||||
debug_info = {
|
||||
"pricing_rows": pricing_count,
|
||||
"providers": provider_list,
|
||||
}
|
||||
|
||||
# Log pricing debug info at warning level
|
||||
logger.warning(f"[PRE-ESTIMATE] Pricing debug: rows={pricing_count}, providers={provider_list}")
|
||||
logger.warning(f"[PRE-ESTIMATE] Models: llm={request.gemini_model}, tts={request.audio_tts_model}, video={request.video_model}")
|
||||
|
||||
if estimate is None:
|
||||
return PodcastPreEstimateResponse(
|
||||
estimate=None,
|
||||
error="Pricing data unavailable. Please try again later.",
|
||||
pricing_available=False,
|
||||
debug=debug_info,
|
||||
)
|
||||
|
||||
return PodcastPreEstimateResponse(
|
||||
estimate=estimate,
|
||||
error=None,
|
||||
pricing_available=True,
|
||||
debug=debug_info,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Pre-estimate error: {e}")
|
||||
return PodcastPreEstimateResponse(
|
||||
estimate=None,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/idea/enhance", response_model=PodcastEnhanceIdeaResponse)
|
||||
async def enhance_podcast_idea(
|
||||
request: PodcastEnhanceIdeaRequest,
|
||||
@@ -76,39 +153,27 @@ async def enhance_podcast_idea(
|
||||
except Exception as exc:
|
||||
logger.debug(f"[Podcast Enhance] Bible parsing skipped in podcast mode: {exc}")
|
||||
|
||||
prompt = f"""
|
||||
You are a creative podcast producer. Generate 3 distinct, compelling podcast episode concepts from the raw idea.
|
||||
# Log what's being used for context
|
||||
context_used = []
|
||||
if bible_context:
|
||||
context_used.append("Podcast Bible")
|
||||
if request.website_data:
|
||||
context_used.append("Website Extraction")
|
||||
if request.topic_context:
|
||||
category = request.topic_context.get("category", "unknown")
|
||||
context_used.append(f"Category Research ({category})")
|
||||
|
||||
{f"USER PERSONALIZATION CONTEXT (Podcast Bible):\n{bible_context}\n" if bible_context else ""}
|
||||
logger.warning(f"[Podcast Enhance] Generating with context: {', '.join(context_used) if context_used else 'basic idea only'}")
|
||||
|
||||
RAW IDEA/KEYWORDS: "{request.idea}"
|
||||
|
||||
TASK:
|
||||
Generate 3 different enhanced versions, each with a unique angle:
|
||||
1. Professional & Expert-led angle (focus on authority, insights, and expertise)
|
||||
2. Storytelling & Human interest angle (focus on narratives, emotions, and personal connections)
|
||||
3. Trendy & Contemporary angle (focus on current trends, modern perspectives, and relevance)
|
||||
|
||||
Each version should be 2-3 sentences, audience-focused, and align with host persona if provided.
|
||||
|
||||
Return JSON with:
|
||||
- enhanced_ideas: array of 3 strings, each string being a complete episode pitch (NOT objects, just plain strings)
|
||||
- rationales: array of 3 strings explaining the approach for each version
|
||||
|
||||
IMPORTANT: enhanced_ideas must be an array of plain strings, NOT objects. Example:
|
||||
{{
|
||||
"enhanced_ideas": [
|
||||
"Your expert guide to AI advancement: A practical look at how AI is transforming industries...",
|
||||
"The human stories behind AI innovation: From Silicon Valley to your daily life...",
|
||||
"AI in 2026: What's trending and what's next in artificial intelligence..."
|
||||
],
|
||||
"rationales": [
|
||||
"Professional approach focusing on expertise and authority",
|
||||
"Storytelling approach emphasizing human connection",
|
||||
"Contemporary approach highlighting current relevance"
|
||||
]
|
||||
}}
|
||||
"""
|
||||
# Use new context builder for prompt generation
|
||||
from services.podcast_context_builder import context_builder
|
||||
context_result = context_builder.build_enhance_context(
|
||||
idea=request.idea,
|
||||
bible_context=bible_context,
|
||||
website_data=request.website_data,
|
||||
topic_context=request.topic_context,
|
||||
)
|
||||
prompt = context_result["prompt"]
|
||||
|
||||
try:
|
||||
raw = llm_text_gen(
|
||||
@@ -246,7 +311,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
|
||||
|
||||
@@ -372,6 +438,13 @@ Requirements:
|
||||
listener_cta = data.get("listener_cta") or ""
|
||||
research_queries = data.get("research_queries") or []
|
||||
exa_suggested_config = data.get("exa_suggested_config") or None
|
||||
estimate = estimate_podcast_cost(
|
||||
db=db,
|
||||
duration_minutes=request.duration,
|
||||
speakers=request.speakers,
|
||||
query_count=len(research_queries) if isinstance(research_queries, list) else 0,
|
||||
include_avatar_phase=podcast_mode != "audio_only",
|
||||
)
|
||||
|
||||
return PodcastAnalyzeResponse(
|
||||
audience=audience,
|
||||
@@ -388,6 +461,7 @@ Requirements:
|
||||
bible=bible_obj.model_dump() if bible_obj else None,
|
||||
avatar_url=final_avatar_url,
|
||||
avatar_prompt=final_avatar_prompt,
|
||||
estimate=estimate,
|
||||
)
|
||||
|
||||
|
||||
@@ -493,3 +567,315 @@ Requirements:
|
||||
logger.error(f"[Regenerate Queries] Failed for user {user_id}: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Regenerate queries failed: {exc}")
|
||||
|
||||
|
||||
@router.post("/extract-url", response_model=ExtractUrlResponse)
|
||||
async def extract_url_content(
|
||||
request: ExtractUrlRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Extract content from a URL using Exa's get_contents API.
|
||||
|
||||
This allows users to paste a blog post or article URL as their podcast topic,
|
||||
and we'll extract the content to use as the podcast idea.
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
from exa_py import Exa
|
||||
import os
|
||||
|
||||
api_key = os.getenv("EXA_API_KEY")
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=500, detail="EXA_API_KEY not configured")
|
||||
|
||||
exa = Exa(api_key)
|
||||
|
||||
logger.warning(f"[ExtractUrl] Extracting content from: {request.url} for user {user_id}")
|
||||
|
||||
try:
|
||||
result = exa.get_contents(
|
||||
urls=[request.url],
|
||||
text=True,
|
||||
highlights=True,
|
||||
summary=True,
|
||||
subpages=2,
|
||||
)
|
||||
except Exception as exa_error:
|
||||
logger.error(f"[ExtractUrl] Exa call error: {exa_error}")
|
||||
return ExtractUrlResponse(
|
||||
success=False,
|
||||
url=request.url,
|
||||
error=f"Exa API error: {str(exa_error)}"
|
||||
)
|
||||
|
||||
# Check for errors using the correct attribute (statuses is array of status objects)
|
||||
if hasattr(result, 'statuses') and result.statuses:
|
||||
for status in result.statuses:
|
||||
if status.status == "error":
|
||||
logger.error(f"[ExtractUrl] Failed to extract {status.id}: {status.error.tag if hasattr(status.error, 'tag') else 'unknown'}")
|
||||
return ExtractUrlResponse(
|
||||
success=False,
|
||||
url=request.url,
|
||||
error=f"Failed to extract content: {status.error.tag if hasattr(status.error, 'tag') else 'unknown error'}"
|
||||
)
|
||||
|
||||
if not result.results:
|
||||
return ExtractUrlResponse(
|
||||
success=False,
|
||||
url=request.url,
|
||||
error="No content found at the provided URL"
|
||||
)
|
||||
|
||||
# Extract content - safe to access result now
|
||||
content = result.results[0]
|
||||
|
||||
# Extract all available fields from Exa response
|
||||
extracted_text = content.text or ""
|
||||
extracted_summary = getattr(content, 'summary', "") or ""
|
||||
extracted_title = content.title or ""
|
||||
|
||||
# Highlights - extract from content.highlights array if available
|
||||
highlights = []
|
||||
if hasattr(content, 'highlights') and content.highlights:
|
||||
highlights = [h for h in content.highlights if h]
|
||||
|
||||
# Additional fields from Exa response
|
||||
image = getattr(content, 'image', None)
|
||||
favicon = getattr(content, 'favicon', None)
|
||||
|
||||
# Subpages - extract with their own content
|
||||
subpages = []
|
||||
if hasattr(content, 'subpages') and content.subpages:
|
||||
for sp in content.subpages:
|
||||
subpages.append({
|
||||
'id': sp.get('id', ''),
|
||||
'title': sp.get('title', ''),
|
||||
'url': sp.get('url', ''),
|
||||
'summary': sp.get('summary', ''),
|
||||
'text': sp.get('text', '')[:500] if sp.get('text') else '', # First 500 chars
|
||||
})
|
||||
|
||||
logger.warning(f"[ExtractUrl] Successfully extracted {len(extracted_text)} chars from {request.url}")
|
||||
logger.warning(f"[ExtractUrl] title={extracted_title[:50]}, summary={extracted_summary[:50]}, highlights={len(highlights)}, subpages={len(subpages)}")
|
||||
|
||||
return ExtractUrlResponse(
|
||||
success=True,
|
||||
title=extracted_title,
|
||||
text=extracted_text,
|
||||
summary=extracted_summary,
|
||||
author=getattr(content, 'author', None),
|
||||
highlights=highlights,
|
||||
url=request.url,
|
||||
image=image,
|
||||
favicon=favicon,
|
||||
subpages=subpages,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/website-analysis", response_model=WebsiteAnalysisResponse)
|
||||
async def save_website_analysis(
|
||||
request: WebsiteAnalysisRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Save the user's website analysis for reuse in future podcasts."""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
try:
|
||||
from services.user_data_service import user_data_service
|
||||
|
||||
website_data = {
|
||||
"website_url": request.website_url,
|
||||
"extracted_at": datetime.now().isoformat(),
|
||||
"exa_content": request.exa_content,
|
||||
"full_analysis": None,
|
||||
"analysis_status": "pending",
|
||||
}
|
||||
|
||||
success = user_data_service.save_user_data(
|
||||
user_id=user_id,
|
||||
data_key="website_analysis",
|
||||
data_value=website_data,
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.warning(f"[WebsiteAnalysis] Saved analysis for user {user_id}: {request.website_url}")
|
||||
return WebsiteAnalysisResponse(
|
||||
success=True,
|
||||
website_url=request.website_url,
|
||||
message="Website analysis saved successfully",
|
||||
)
|
||||
else:
|
||||
return WebsiteAnalysisResponse(
|
||||
success=False,
|
||||
error="Failed to save website analysis",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[WebsiteAnalysis] Failed to save for user {user_id}: {exc}")
|
||||
return WebsiteAnalysisResponse(
|
||||
success=False,
|
||||
error=f"Failed to save: {str(exc)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/website-extraction")
|
||||
async def get_saved_website_extraction(request: Request = None):
|
||||
"""Get previously saved website extraction data for this user."""
|
||||
try:
|
||||
# Safely get current_user from Depends
|
||||
if request is None or not hasattr(request, 'state'):
|
||||
logger.warning("[WebsiteExtraction] No request or state - user not authenticated")
|
||||
return {"success": False, "data": None, "error": "Not authenticated"}
|
||||
|
||||
current_user = getattr(request.state, 'user', None)
|
||||
if not current_user:
|
||||
logger.warning("[WebsiteExtraction] No user in request state")
|
||||
return {"success": False, "data": None, "error": "Not authenticated"}
|
||||
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
from services.user_data_service import UserDataService
|
||||
from services.database import get_db
|
||||
db = next(get_db())
|
||||
|
||||
user_service = UserDataService(db)
|
||||
extraction = user_service.get_website_extraction(user_id)
|
||||
|
||||
if extraction:
|
||||
logger.info(f"[WebsiteExtraction] Found saved data for user {user_id}")
|
||||
return {
|
||||
"success": True,
|
||||
"data": extraction
|
||||
}
|
||||
else:
|
||||
logger.info(f"[WebsiteExtraction] No saved data for user {user_id}")
|
||||
return {
|
||||
"success": False,
|
||||
"data": None
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[WebsiteExtraction] Failed for user: {exc}", exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(exc)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/website-extraction")
|
||||
async def save_website_extraction(
|
||||
extraction: Dict[str, Any],
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Save website extraction data for future use."""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
try:
|
||||
from services.user_data_service import UserDataService
|
||||
from services.database import get_db
|
||||
db = next(get_db())
|
||||
|
||||
user_service = UserDataService(db)
|
||||
success = user_service.save_website_extraction(user_id, extraction)
|
||||
|
||||
if success:
|
||||
logger.info(f"[WebsiteExtraction] Saved for user {user_id}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Website extraction saved"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Failed to save"
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[WebsiteExtraction] Save failed: {exc}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(exc)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/project/{project_id}/topic-context")
|
||||
async def save_topic_context(
|
||||
project_id: str,
|
||||
topic_context: Dict[str, Any],
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Save topic context (category research) to a podcast project."""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
try:
|
||||
from services.database import get_db
|
||||
from models.podcast_models import PodcastProject
|
||||
|
||||
db = next(get_db())
|
||||
|
||||
# Find the project
|
||||
project = db.query(PodcastProject).filter(
|
||||
PodcastProject.project_id == project_id,
|
||||
PodcastProject.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not project:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Project not found"
|
||||
}
|
||||
|
||||
# Update topic context
|
||||
project.topic_context = topic_context
|
||||
db.commit()
|
||||
|
||||
logger.info(f"[TopicContext] Saved for project {project_id}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Topic context saved"
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[TopicContext] Save failed: {exc}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(exc)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/project/{project_id}/topic-context")
|
||||
async def get_topic_context(
|
||||
project_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Get topic context from a podcast project."""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
try:
|
||||
from services.database import get_db
|
||||
from models.podcast_models import PodcastProject
|
||||
|
||||
db = next(get_db())
|
||||
|
||||
project = db.query(PodcastProject).filter(
|
||||
PodcastProject.project_id == project_id,
|
||||
PodcastProject.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not project:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Project not found"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": project.topic_context
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[TopicContext] Get failed: {exc}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(exc)
|
||||
}
|
||||
|
||||
@@ -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,15 +251,163 @@ async def generate_podcast_audio(
|
||||
raise HTTPException(status_code=400, detail="Text is required")
|
||||
|
||||
try:
|
||||
# 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"
|
||||
)
|
||||
|
||||
# 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
|
||||
from utils.media_utils import detect_audio_format
|
||||
|
||||
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}")
|
||||
|
||||
# Detect actual audio format from bytes (may differ from file extension)
|
||||
detected_fmt, detected_mime = detect_audio_format(voice_sample_bytes)
|
||||
logger.warning(f"[Podcast] 🔊 Detected voice sample format: {detected_fmt} ({detected_mime}), {len(voice_sample_bytes)} bytes")
|
||||
voice_mime_type = detected_mime or "audio/wav"
|
||||
|
||||
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_mime_type=voice_mime_type,
|
||||
),
|
||||
)
|
||||
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_mime_type=voice_mime_type,
|
||||
),
|
||||
)
|
||||
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)
|
||||
logger.warning(f"[Podcast] Generating audio with service dir: {audio_service.output_dir}")
|
||||
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=request.voice_id or "Wise_Woman",
|
||||
custom_voice_id=request.custom_voice_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)
|
||||
@@ -153,8 +427,14 @@ async def generate_podcast_audio(
|
||||
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 HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Audio generation failed: {exc}")
|
||||
exc_type = type(exc).__name__
|
||||
exc_msg = str(exc)[:500]
|
||||
logger.error(f"[Podcast] Audio generation failed ({exc_type}): {exc_msg}")
|
||||
logger.error(f"[Podcast] Audio generation traceback:", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Audio generation failed ({exc_type}): {exc_msg}")
|
||||
|
||||
# Save to asset library (podcast module)
|
||||
try:
|
||||
@@ -391,7 +671,10 @@ async def serve_podcast_audio(
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
user_id = require_authenticated_user(current_user)
|
||||
logger.debug(f"[Podcast] serve_podcast_audio called: user_id={user_id}, filename={filename}")
|
||||
logger.info(f"[Podcast] serve_podcast_audio: filename={filename}, user_id={user_id}")
|
||||
|
||||
audio_path = _resolve_podcast_media_file(filename, "audio", user_id)
|
||||
logger.info(f"[Podcast] Audio resolved path: {audio_path}, exists={audio_path.exists()}")
|
||||
audio_path = _resolve_podcast_media_file(filename, "audio", user_id)
|
||||
logger.debug(f"[Podcast] Resolved audio path: {audio_path}")
|
||||
|
||||
|
||||
@@ -12,22 +12,39 @@ from pathlib import Path
|
||||
import uuid
|
||||
import hashlib
|
||||
|
||||
from services.database import get_db
|
||||
from services.database import get_db, get_session_for_user
|
||||
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
|
||||
from api.story_writer.utils.auth import require_authenticated_user
|
||||
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
|
||||
|
||||
|
||||
async def _get_db_or_none(current_user: Dict[str, Any]):
|
||||
"""Try to get a database session, returning None on failure (non-fatal for uploads)."""
|
||||
try:
|
||||
user_id = current_user.get('id') or current_user.get('clerk_user_id')
|
||||
if not user_id:
|
||||
return None
|
||||
return get_session_for_user(user_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Podcast] DB session unavailable (non-fatal): {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_podcast_avatars_dir(user_id: str) -> Path:
|
||||
"""Get podcast avatars directory for a user (workspace-aware)."""
|
||||
avatars_dir = get_podcast_media_dir("image", user_id, ensure_exists=True) / AVATAR_SUBDIR
|
||||
avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||
return avatars_dir
|
||||
|
||||
|
||||
@router.post("/avatar/upload")
|
||||
@@ -41,7 +58,15 @@ async def upload_podcast_avatar(
|
||||
Upload a presenter avatar image for a podcast project.
|
||||
Returns the avatar URL for use in scene image generation.
|
||||
"""
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Podcast] Avatar upload auth failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
logger.info(f"[Podcast] Avatar upload request - user_id={user_id}, project_id={project_id}, content_type={file.content_type}")
|
||||
|
||||
# Validate file type
|
||||
if not file.content_type or not file.content_type.startswith('image/'):
|
||||
@@ -57,19 +82,21 @@ 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)
|
||||
logger.info(f"[Podcast] Saving avatar to: {avatars_dir / avatar_filename}")
|
||||
avatar_path = avatars_dir / avatar_filename
|
||||
|
||||
# Save file
|
||||
with open(avatar_path, "wb") as f:
|
||||
f.write(file_content)
|
||||
|
||||
logger.info(f"[Podcast] Avatar uploaded: {avatar_path}")
|
||||
logger.info(f"[Podcast] Avatar uploaded successfully: {avatar_path}")
|
||||
|
||||
# Create avatar URL
|
||||
avatar_url = f"/api/podcast/images/{AVATAR_SUBDIR}/{avatar_filename}"
|
||||
|
||||
# Save to asset library if project_id provided
|
||||
if project_id:
|
||||
# Save to asset library if project_id provided and DB session available
|
||||
if project_id and db:
|
||||
try:
|
||||
save_asset_to_library(
|
||||
db=db,
|
||||
@@ -91,13 +118,17 @@ async def upload_podcast_avatar(
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Podcast] Failed to save avatar asset: {e}")
|
||||
logger.warning(f"[Podcast] Failed to save avatar asset (non-fatal): {e}")
|
||||
elif project_id and not db:
|
||||
logger.warning(f"[Podcast] DB session unavailable, skipping asset library save for avatar")
|
||||
|
||||
return {
|
||||
"avatar_url": avatar_url,
|
||||
"avatar_filename": avatar_filename,
|
||||
"message": "Avatar uploaded successfully"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"[Podcast] Avatar upload failed: {exc}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Avatar upload failed: {str(exc)}")
|
||||
@@ -124,7 +155,7 @@ async def make_avatar_presentable(
|
||||
# Load the uploaded avatar image
|
||||
from ..utils import load_podcast_image_bytes
|
||||
logger.info(f"[Podcast] Loading avatar image from {avatar_url}")
|
||||
avatar_bytes = load_podcast_image_bytes(avatar_url)
|
||||
avatar_bytes = load_podcast_image_bytes(avatar_url, user_id=user_id)
|
||||
logger.info(f"[Podcast] Avatar loaded successfully - size={len(avatar_bytes)} bytes")
|
||||
|
||||
logger.info(f"[Podcast] Transforming avatar to podcast presenter for project {project_id}")
|
||||
@@ -163,7 +194,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 +377,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)
|
||||
|
||||
@@ -4,19 +4,125 @@ B-Roll Handlers
|
||||
API endpoints for B-roll chart preview and video generation.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import FileResponse
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
|
||||
from api.story_writer.utils.auth import require_authenticated_user
|
||||
from api.story_writer.task_manager import task_manager
|
||||
from api.podcast.utils import _resolve_podcast_media_file
|
||||
from services.podcast.broll_service import get_broll_service
|
||||
from utils.media_utils import resolve_media_path
|
||||
from loguru import logger
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(prefix="/broll", tags=["B-Roll"])
|
||||
|
||||
|
||||
def _resolve_broll_background_image_path(background_image_url: str) -> str:
|
||||
"""Resolve background image URL/path to a local file path."""
|
||||
resolved = resolve_media_path(background_image_url)
|
||||
if not resolved:
|
||||
raise HTTPException(status_code=404, detail=f"Background image not found: {background_image_url}")
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def _resolve_broll_avatar_video_path(avatar_video_url: Optional[str], user_id: str) -> Optional[str]:
|
||||
"""Resolve optional avatar video URL/path to a local file path."""
|
||||
if not avatar_video_url:
|
||||
return None
|
||||
|
||||
parsed = urlparse(avatar_video_url)
|
||||
path = parsed.path if parsed.scheme else avatar_video_url
|
||||
|
||||
if "/api/podcast/videos/" in path:
|
||||
filename = path.split("/api/podcast/videos/", 1)[1].split("?", 1)[0].strip()
|
||||
if not filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid avatar video URL")
|
||||
return str(_resolve_podcast_media_file(filename, "video", user_id))
|
||||
|
||||
local_path = Path(path).expanduser().resolve()
|
||||
if local_path.exists() and local_path.is_file():
|
||||
return str(local_path)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Unsupported avatar video URL format. "
|
||||
"Use /api/podcast/videos/{filename} or a valid local file path."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _execute_broll_scene_task(
|
||||
task_id: str,
|
||||
*,
|
||||
scene_id: str,
|
||||
key_insight: str,
|
||||
supporting_stat: str,
|
||||
chart_data: Optional[Dict[str, Any]],
|
||||
visual_cue: str,
|
||||
duration: float,
|
||||
background_img_path: str,
|
||||
avatar_video_path: Optional[str],
|
||||
):
|
||||
"""Background task for rendering a B-roll scene."""
|
||||
try:
|
||||
task_manager.update_task_status(
|
||||
task_id,
|
||||
"processing",
|
||||
progress=10.0,
|
||||
message="Starting B-roll scene render...",
|
||||
)
|
||||
|
||||
broll_service = get_broll_service()
|
||||
task_manager.update_task_status(
|
||||
task_id,
|
||||
"processing",
|
||||
progress=35.0,
|
||||
message="Composing scene layers and overlays...",
|
||||
)
|
||||
|
||||
video_path = broll_service.generate_scene_broll(
|
||||
scene_id=scene_id,
|
||||
key_insight=key_insight,
|
||||
supporting_stat=supporting_stat,
|
||||
chart_data=chart_data,
|
||||
visual_cue=visual_cue,
|
||||
duration=duration,
|
||||
background_img_path=background_img_path,
|
||||
avatar_video_path=avatar_video_path,
|
||||
)
|
||||
|
||||
filename = Path(video_path).name
|
||||
video_url = f"/api/podcast/broll/final/{filename}"
|
||||
|
||||
task_manager.update_task_status(
|
||||
task_id,
|
||||
"completed",
|
||||
progress=100.0,
|
||||
message="B-roll scene render completed.",
|
||||
result={
|
||||
"scene_id": scene_id,
|
||||
"broll_video_path": video_path,
|
||||
"broll_video_url": video_url,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"[Broll] Task {task_id} failed: {exc}")
|
||||
task_manager.update_task_status(
|
||||
task_id,
|
||||
"failed",
|
||||
error=f"B-roll scene render failed: {str(exc)}",
|
||||
error_status=500,
|
||||
)
|
||||
|
||||
|
||||
class ChartPreviewRequest(BaseModel):
|
||||
@@ -42,7 +148,7 @@ class BrollSceneRequest(BaseModel):
|
||||
key_insight: str
|
||||
supporting_stat: str
|
||||
chart_data: Optional[Dict[str, Any]] = None
|
||||
visual_cue: str = Field(default="bar_chart_comparison", description="bar_chart_comparison | bullet_points")
|
||||
visual_cue: str = Field(default="bar_comparison", description="bar_comparison | bar_horizontal | line_trend | pie | stacked_bar | bullet_points | full_avatar")
|
||||
duration: float = Field(default=10.0, ge=3.0, le=60.0)
|
||||
background_image_url: str
|
||||
avatar_video_url: Optional[str] = None
|
||||
@@ -51,8 +157,11 @@ class BrollSceneRequest(BaseModel):
|
||||
class BrollSceneResponse(BaseModel):
|
||||
"""Response for B-roll scene generation."""
|
||||
scene_id: str
|
||||
broll_video_url: str
|
||||
broll_video_path: str
|
||||
broll_video_url: str = ""
|
||||
broll_video_path: str = ""
|
||||
task_id: Optional[str] = None
|
||||
status: str = "completed"
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class BrollComposeRequest(BaseModel):
|
||||
@@ -82,21 +191,34 @@ 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(
|
||||
chart_data=request.chart_data,
|
||||
chart_type=request.chart_type,
|
||||
title=request.title,
|
||||
subtitle=request.subtitle or "",
|
||||
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,
|
||||
)
|
||||
|
||||
chart_id = uuid.uuid4().hex[:8]
|
||||
preview_url = f"/api/podcast/broll/preview/{chart_id}/{preview_path.split('/')[-1]}"
|
||||
preview_filename = Path(preview_path).name
|
||||
preview_url = f"/api/podcast/broll/preview/{chart_id}/{preview_filename}"
|
||||
|
||||
logger.warning(f"[Broll] Chart preview generated: chart_id={chart_id}, path={preview_path}, url={preview_url}")
|
||||
|
||||
return ChartPreviewResponse(
|
||||
preview_url=preview_url,
|
||||
@@ -129,23 +251,42 @@ async def generate_broll_scene(
|
||||
|
||||
try:
|
||||
# Validate visual_cue
|
||||
valid_cues = ["bar_chart_comparison", "bullet_points", "full_avatar"]
|
||||
valid_cues = ["bar_comparison", "bar_chart_comparison", "bar_horizontal", "line_trend", "pie", "stacked_bar", "bullet_points", "full_avatar"]
|
||||
if request.visual_cue not in valid_cues:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid visual_cue. Must be one of: {valid_cues}"
|
||||
)
|
||||
|
||||
# For now, return a placeholder - full video generation requires
|
||||
# resolving image/video URLs to actual file paths
|
||||
# In V2, this will integrate with the actual video generation
|
||||
background_img_path = _resolve_broll_background_image_path(request.background_image_url)
|
||||
avatar_video_path = _resolve_broll_avatar_video_path(request.avatar_video_url, user_id)
|
||||
|
||||
logger.info(f"[Broll] B-roll scene request for scene: {request.scene_id}")
|
||||
|
||||
# Scene rendering can be expensive, so use task manager/background execution.
|
||||
task_id = task_manager.create_task(
|
||||
"podcast_broll_scene_generation",
|
||||
metadata={"owner_user_id": user_id, "scene_id": request.scene_id},
|
||||
)
|
||||
|
||||
background_tasks.add_task(
|
||||
_execute_broll_scene_task,
|
||||
task_id=task_id,
|
||||
scene_id=request.scene_id,
|
||||
key_insight=request.key_insight,
|
||||
supporting_stat=request.supporting_stat,
|
||||
chart_data=request.chart_data,
|
||||
visual_cue=request.visual_cue,
|
||||
duration=request.duration,
|
||||
background_img_path=background_img_path,
|
||||
avatar_video_path=avatar_video_path,
|
||||
)
|
||||
|
||||
return BrollSceneResponse(
|
||||
scene_id=request.scene_id,
|
||||
broll_video_url="",
|
||||
broll_video_path="",
|
||||
task_id=task_id,
|
||||
status="pending",
|
||||
message="B-roll scene render started. Poll /api/podcast/task/{task_id}/status for progress.",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
@@ -194,19 +335,35 @@ async def compose_broll_videos(
|
||||
async def serve_chart_preview(
|
||||
chart_id: str,
|
||||
filename: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
|
||||
):
|
||||
"""Serve chart preview PNG files."""
|
||||
from pathlib import Path
|
||||
"""
|
||||
Serve chart preview PNG files.
|
||||
|
||||
Uses authentication via Authorization header or token query parameter,
|
||||
matching the pattern used by /api/podcast/images/ for browser <img> tags.
|
||||
"""
|
||||
from api.podcast.constants import get_podcast_media_dir
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
broll_service = get_broll_service()
|
||||
file_path = broll_service.output_dir / f"chart_preview_{chart_id}.png"
|
||||
# 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}")
|
||||
|
||||
charts_dir = get_podcast_media_dir("chart", user_id)
|
||||
file_path = charts_dir / 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")
|
||||
|
||||
# Security: ensure resolved path is within charts_dir
|
||||
if not str(file_path.resolve()).startswith(str(charts_dir.resolve())):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type="image/png",
|
||||
|
||||
@@ -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()
|
||||
@@ -69,7 +69,7 @@ async def generate_podcast_scene_image(
|
||||
from ..utils import load_podcast_image_bytes
|
||||
try:
|
||||
logger.info(f"[Podcast] Attempting to load base avatar from: {request.base_avatar_url}")
|
||||
base_avatar_bytes = load_podcast_image_bytes(request.base_avatar_url)
|
||||
base_avatar_bytes = load_podcast_image_bytes(request.base_avatar_url, user_id=user_id)
|
||||
logger.info(f"[Podcast] ✅ Successfully loaded base avatar ({len(base_avatar_bytes)} bytes) for scene {request.scene_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Podcast] ❌ Failed to load base avatar from {request.base_avatar_url}: {e}", exc_info=True)
|
||||
@@ -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,25 +107,57 @@ 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")
|
||||
|
||||
# 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)
|
||||
|
||||
# Check if project exists; if not, create it (upsert behavior for resilience)
|
||||
existing = service.get_project(user_id, project_id)
|
||||
if not existing:
|
||||
logger.warning(f"[Podcast] Project {project_id} not found for user {user_id}, creating new project with default values")
|
||||
# Try to create the project - this handles cases where create succeeded but wasn't found later
|
||||
# (can happen with user_id mismatch or after session refresh)
|
||||
try:
|
||||
project = service.create_project(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
idea="Untitled Podcast",
|
||||
status="scripting",
|
||||
duration=10,
|
||||
speakers=1,
|
||||
budget_cap=0.0,
|
||||
)
|
||||
except Exception as create_err:
|
||||
logger.error(f"[Podcast] Failed to create project {project_id}: {create_err}")
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found and could not create: {create_err}")
|
||||
else:
|
||||
# Convert request to dict, excluding None values
|
||||
updates = request.model_dump(exclude_unset=True)
|
||||
|
||||
project = service.update_project(user_id, project_id, **updates)
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
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,10 +9,13 @@ 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
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from api.story_writer.utils.auth import require_authenticated_user
|
||||
from services.database import get_db
|
||||
from services.blog_writer.research.exa_provider import ExaResearchProvider
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from services.podcast_bible_service import PodcastBibleService
|
||||
@@ -20,6 +23,7 @@ from services.database import get_db
|
||||
from services.subscription import PricingService
|
||||
from models.subscription_models import APIProvider
|
||||
from loguru import logger
|
||||
from ..cost_estimator import estimate_podcast_cost
|
||||
from ..models import (
|
||||
PodcastExaResearchRequest,
|
||||
PodcastExaResearchResponse,
|
||||
@@ -60,6 +64,7 @@ def _build_research_cost_estimate(
|
||||
raw_content: str,
|
||||
sources_count: int,
|
||||
provider_result: Dict[str, Any],
|
||||
user_id: str = "default",
|
||||
) -> PodcastCostEst:
|
||||
# Fallback defaults mirror current catalog defaults.
|
||||
exa_per_request = 0.005
|
||||
@@ -67,7 +72,9 @@ def _build_research_cost_estimate(
|
||||
gemini_out_token = 0.0000006
|
||||
|
||||
try:
|
||||
db = next(get_db())
|
||||
from services.database import get_session_for_user
|
||||
db = get_session_for_user(user_id)
|
||||
if db:
|
||||
try:
|
||||
pricing_service = PricingService(db)
|
||||
exa_per_request = _get_price_from_catalog(
|
||||
@@ -126,15 +133,18 @@ def _build_research_cost_estimate(
|
||||
async def podcast_research_exa(
|
||||
request: PodcastExaResearchRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
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()]
|
||||
@@ -214,6 +224,9 @@ Listener CTA: {request.analysis.get('listener_cta', 'N/A')}
|
||||
|
||||
summary = ""
|
||||
key_insights = []
|
||||
expert_quotes = []
|
||||
listener_cta_suggestions = []
|
||||
mapped_angles = []
|
||||
|
||||
if raw_content and sources:
|
||||
logger.warning(f"[Podcast Research] Extracting insights from {len(sources)} sources for user {user_id}")
|
||||
@@ -333,13 +346,22 @@ QUALITY STANDARDS:
|
||||
try:
|
||||
summary = data.get("summary", "")
|
||||
key_insights = [PodcastResearchInsight(**insight) for insight in data.get("key_insights", [])]
|
||||
expert_quotes = data.get("expert_quotes", [])
|
||||
listener_cta_suggestions = data.get("listener_cta_suggestions", [])
|
||||
mapped_angles = data.get("mapped_angles", [])
|
||||
except Exception as insight_err:
|
||||
logger.warning(f"[Podcast Research] Failed to parse insights: {insight_err}. Data keys: {list(data.keys()) if isinstance(data, dict) else 'not a dict'}")
|
||||
summary = data.get("summary", "") if isinstance(data, dict) else ""
|
||||
key_insights = []
|
||||
expert_quotes = data.get("expert_quotes", []) if isinstance(data, dict) else []
|
||||
listener_cta_suggestions = data.get("listener_cta_suggestions", []) if isinstance(data, dict) else []
|
||||
mapped_angles = data.get("mapped_angles", []) if isinstance(data, dict) else []
|
||||
else:
|
||||
summary = ""
|
||||
key_insights = []
|
||||
expert_quotes = []
|
||||
listener_cta_suggestions = []
|
||||
mapped_angles = []
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -391,6 +413,24 @@ QUALITY STANDARDS:
|
||||
"credibility_score": src.get("credibility_score"),
|
||||
}))
|
||||
|
||||
duration_minutes = 10
|
||||
speakers = 1
|
||||
if request.analysis:
|
||||
duration_minutes = int(request.analysis.get("duration", 10) or 10)
|
||||
speakers = int(request.analysis.get("speakers", 1) or 1)
|
||||
|
||||
estimate = estimate_podcast_cost(
|
||||
db=db,
|
||||
duration_minutes=duration_minutes,
|
||||
speakers=speakers,
|
||||
query_count=len(queries),
|
||||
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,
|
||||
@@ -401,8 +441,13 @@ QUALITY STANDARDS:
|
||||
raw_content=raw_content,
|
||||
sources_count=len(sources_payload),
|
||||
provider_result=result if isinstance(result, dict) else {},
|
||||
user_id=user_id,
|
||||
),
|
||||
search_type=result.get("search_type") if isinstance(result, dict) else None,
|
||||
provider=result.get("provider", "exa") if isinstance(result, dict) else "exa",
|
||||
content=raw_content,
|
||||
mapped_angles=mapped_angles,
|
||||
expert_quotes=expert_quotes,
|
||||
listener_cta_suggestions=listener_cta_suggestions,
|
||||
estimate=estimate,
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
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
|
||||
@@ -23,6 +25,8 @@ from ..models import (
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
MAX_TTS_CHARS_PER_REQUEST = 10_000
|
||||
TARGET_TTS_CHARS_PER_SCENE = 8_500
|
||||
|
||||
|
||||
class SceneApprovalRequest(BaseModel):
|
||||
@@ -57,31 +61,46 @@ async def generate_podcast_script(
|
||||
Generate a podcast script outline (scenes + lines) using podcast-oriented prompting.
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
logger.warning(f"[ScriptGen] ========== SCRIPT GENERATION START ==========")
|
||||
logger.warning(f"[ScriptGen] Topic: {request.idea[:60]}...")
|
||||
logger.warning(f"[ScriptGen] Duration: {request.duration_minutes} min, Speakers: {request.speakers}")
|
||||
logger.warning(f"[ScriptGen] Has research: {bool(request.research)}, Has bible: {bool(request.bible)}, Has analysis: {bool(request.analysis)}")
|
||||
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] 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
|
||||
research_context = ""
|
||||
if request.research:
|
||||
try:
|
||||
key_insights = request.research.get("keyword_analysis", {}).get("key_insights") or []
|
||||
fact_cards = request.research.get("factCards", []) or []
|
||||
fact_cards = research_fact_cards or []
|
||||
mapped_angles = request.research.get("mappedAngles", []) or []
|
||||
sources = request.research.get("sources", []) or []
|
||||
|
||||
top_facts = [f.get("quote", "") for f in fact_cards[:5] if f.get("quote")]
|
||||
top_facts = [
|
||||
f"[{f.get('id') or f'fact_{idx + 1}'}] {f.get('quote', '')}"
|
||||
for idx, f in enumerate(fact_cards[:10])
|
||||
if f.get("quote")
|
||||
]
|
||||
angles_summary = [
|
||||
f"{a.get('title', '')}: {a.get('why', '')}" for a in mapped_angles[:3] if a.get("title") or a.get("why")
|
||||
]
|
||||
top_sources = [s.get("url") for s in sources[:3] if s.get("url")]
|
||||
numeric_signals = []
|
||||
for f in fact_cards[:12]:
|
||||
quote = (f.get("quote") or "").strip()
|
||||
if any(ch.isdigit() for ch in quote):
|
||||
numeric_signals.append(quote[:180])
|
||||
if len(numeric_signals) >= 5:
|
||||
break
|
||||
|
||||
research_parts = []
|
||||
if key_insights:
|
||||
research_parts.append(f"Key Insights: {', '.join(key_insights[:5])}")
|
||||
if top_facts:
|
||||
research_parts.append(f"Key Facts: {', '.join(top_facts)}")
|
||||
if numeric_signals:
|
||||
research_parts.append(f"Numeric Signals (prefer for chart scenes): {' | '.join(numeric_signals)}")
|
||||
if angles_summary:
|
||||
research_parts.append(f"Research Angles: {' | '.join(angles_summary)}")
|
||||
if top_sources:
|
||||
@@ -92,6 +111,53 @@ async def generate_podcast_script(
|
||||
logger.warning(f"Failed to parse research context: {exc}")
|
||||
research_context = ""
|
||||
|
||||
def _normalize_fact_ids(value: Any) -> Optional[list[str]]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, list):
|
||||
cleaned = [str(v).strip() for v in value if str(v).strip()]
|
||||
return cleaned or None
|
||||
if isinstance(value, str) and value.strip():
|
||||
return [value.strip()]
|
||||
return None
|
||||
|
||||
def _default_chart_data(scene_title: str) -> Dict[str, Any]:
|
||||
numeric_pairs: list[tuple[str, float]] = []
|
||||
for fact in research_fact_cards[:12]:
|
||||
quote = (fact.get("quote") or "").strip()
|
||||
if not quote:
|
||||
continue
|
||||
nums = re.findall(r"\d+(?:\.\d+)?", quote.replace(",", ""))
|
||||
if not nums:
|
||||
continue
|
||||
label = quote[:48] + ("…" if len(quote) > 48 else "")
|
||||
try:
|
||||
numeric_pairs.append((label, float(nums[0])))
|
||||
except ValueError:
|
||||
continue
|
||||
if len(numeric_pairs) >= 5:
|
||||
break
|
||||
|
||||
if numeric_pairs:
|
||||
labels = [p[0] for p in numeric_pairs]
|
||||
values = [p[1] for p in numeric_pairs]
|
||||
sources = [f.get("url", f.get("source", "")) for f in research_fact_cards[:12] if f.get("url") or f.get("source")]
|
||||
return {
|
||||
"type": "bar_comparison",
|
||||
"title": scene_title,
|
||||
"labels": labels,
|
||||
"values": values,
|
||||
"takeaway": "Data points sourced from research facts used in this scene.",
|
||||
"source": sources[0] if sources else "",
|
||||
}
|
||||
|
||||
return {
|
||||
"type": "bullet_points",
|
||||
"title": scene_title,
|
||||
"bullet_points": ["Key point 1", "Key point 2", "Key point 3"],
|
||||
"takeaway": "Narration summary for this scene.",
|
||||
}
|
||||
|
||||
# Extract Podcast Bible context for hyper-personalization
|
||||
bible_context = ""
|
||||
if request.bible:
|
||||
@@ -122,25 +188,62 @@ async def generate_podcast_script(
|
||||
except:
|
||||
pass
|
||||
|
||||
mode_instructions = ""
|
||||
if podcast_mode == "audio_only":
|
||||
mode_instructions = f"""
|
||||
AUDIO-ONLY MODE RULES (CRITICAL):
|
||||
- This is an audio-only episode. Do NOT include avatar/image/camera instructions.
|
||||
- Keep each scene's total dialogue under {TARGET_TTS_CHARS_PER_SCENE} chars to stay below TTS max request size ({MAX_TTS_CHARS_PER_REQUEST}).
|
||||
- For every scene include chart_data so B-roll charts can be generated while narration plays.
|
||||
- Build script STRICTLY from RESEARCH context and cite fact linkage via usedFactIds.
|
||||
- If evidence is weak, say uncertainty explicitly rather than inventing facts.
|
||||
- Add natural TTS pacing in dialogue with markers like [pause:300ms], [pause:700ms], [emote:curious], [emote:serious].
|
||||
"""
|
||||
elif podcast_mode == "audio_video":
|
||||
mode_instructions = """
|
||||
AUDIO+VIDEO MODE:
|
||||
- Include rich narration that works for both listening and visual storytelling.
|
||||
- Use a balanced pace suitable for TTS and scene visuals.
|
||||
"""
|
||||
else:
|
||||
mode_instructions = """
|
||||
VIDEO-ONLY MODE:
|
||||
- Prioritize visual rhythm and concise narration per scene.
|
||||
"""
|
||||
|
||||
prompt = f"""Create a podcast script with scenes and dialogue.
|
||||
|
||||
{f"BIBLE: {bible_context[:1500]}" if bible_context else ""}
|
||||
{f"{analysis_context}" if analysis_context else ""}
|
||||
{f"{outline_context}" if outline_context else ""}
|
||||
{f"RESEARCH: {research_context[:1200]}" if research_context else ""}
|
||||
{f"RESEARCH: {research_context[:2500]}" if research_context else ""}
|
||||
{mode_instructions}
|
||||
|
||||
Topic: "{request.idea}"
|
||||
Duration: {request.duration_minutes} min | Speakers: {request.speakers}
|
||||
Podcast mode: {podcast_mode}
|
||||
|
||||
Return JSON with scenes array. Each scene:
|
||||
- id: string
|
||||
- title: short title (<=50 chars)
|
||||
- duration: seconds (total/5)
|
||||
- emotion: neutral|happy|excited|serious|curious|confident
|
||||
- lines: array of {{speaker, text, emphasis}}
|
||||
- lines: array of {{speaker, text, emphasis, usedFactIds, ttsHints}}
|
||||
- Use 2-4 LINES PER SCENE (shorter script = lower TTS costs)
|
||||
- Each line: 1-3 sentences, conversational
|
||||
- usedFactIds: include related fact ids when research facts are available (example: ["fact_1", "fact_3"])
|
||||
- ttsHints: optional list from [pause_300ms, pause_700ms, smile, serious_tone, emphasize_data]
|
||||
- Plain text only, no markdown
|
||||
- chart_data: object for B-roll mapping (required in audio_only)
|
||||
- type: bar_comparison|bar_horizontal|line_trend|pie|stacked_bar|bullet_points
|
||||
- title: short chart title
|
||||
- labels: list
|
||||
- values: list (same length as labels, required for bar/line/pie)
|
||||
- before/after: parallel lists of numbers (for bar_comparison only)
|
||||
- segments: list of {{name, values}} (for stacked_bar only)
|
||||
- bullet_points: list of strings (for bullet_points only)
|
||||
- takeaway: one sentence tying chart to narration
|
||||
- source: URL or citation for the data (e.g. "Research fact #3" or a URL from the research context)
|
||||
|
||||
COST OPTIMIZATION:
|
||||
- 5-6 scenes max for {request.duration_minutes} min episode
|
||||
@@ -231,7 +334,8 @@ COST OPTIMIZATION:
|
||||
line_id = line.get("id") or f"line-{idx + 1}-{line_idx + 1}"
|
||||
|
||||
# Get used fact IDs if provided
|
||||
used_fact_ids = line.get("usedFactIds") or line.get("used_fact_ids") or None
|
||||
used_fact_ids = _normalize_fact_ids(line.get("usedFactIds") or line.get("used_fact_ids"))
|
||||
tts_hints = line.get("ttsHints") or line.get("tts_hints") or None
|
||||
|
||||
if text:
|
||||
lines.append(PodcastSceneLine(
|
||||
@@ -239,7 +343,8 @@ COST OPTIMIZATION:
|
||||
text=text,
|
||||
emphasis=emphasis,
|
||||
id=line_id,
|
||||
usedFactIds=used_fact_ids
|
||||
usedFactIds=used_fact_ids,
|
||||
ttsHints=tts_hints if isinstance(tts_hints, list) else None,
|
||||
))
|
||||
total_lines_output += 1
|
||||
else:
|
||||
@@ -255,6 +360,33 @@ COST OPTIMIZATION:
|
||||
if audio_url_raw:
|
||||
logger.warning(f"[ScriptGen] Scene {idx} has audioUrl - will be reset to None")
|
||||
|
||||
# Keep each scene under TTS request size to prevent failures
|
||||
scene_char_count = sum(len((l.text or "").strip()) for l in lines)
|
||||
if scene_char_count > TARGET_TTS_CHARS_PER_SCENE and lines:
|
||||
logger.warning(
|
||||
f"[ScriptGen] Scene {idx} text too long ({scene_char_count} chars). "
|
||||
f"Trimming to {TARGET_TTS_CHARS_PER_SCENE} target."
|
||||
)
|
||||
trimmed_lines: list[PodcastSceneLine] = []
|
||||
remaining = TARGET_TTS_CHARS_PER_SCENE
|
||||
for l in lines:
|
||||
if remaining <= 0:
|
||||
break
|
||||
line_text = (l.text or "").strip()
|
||||
if len(line_text) <= remaining:
|
||||
trimmed_lines.append(l)
|
||||
remaining -= len(line_text)
|
||||
continue
|
||||
l.text = f"{line_text[:max(0, remaining - 1)].rstrip()}…"
|
||||
trimmed_lines.append(l)
|
||||
remaining = 0
|
||||
lines = trimmed_lines
|
||||
|
||||
chart_data = scene.get("chart_data") or scene.get("chartData") or None
|
||||
if podcast_mode == "audio_only" and not chart_data:
|
||||
# Ensure audio-only always has a B-roll mapping fallback
|
||||
chart_data = _default_chart_data(title)
|
||||
|
||||
scenes.append(
|
||||
PodcastScene(
|
||||
id=scene.get("id") or f"scene-{idx + 1}",
|
||||
@@ -266,6 +398,7 @@ COST OPTIMIZATION:
|
||||
imageUrl=None, # Will be generated later
|
||||
audioUrl=None, # Will be generated later
|
||||
imagePrompt=None, # Will be generated during image generation
|
||||
chart_data=chart_data if isinstance(chart_data, dict) else None,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -274,5 +407,7 @@ COST OPTIMIZATION:
|
||||
if dropped_empty_lines > 0:
|
||||
logger.warning(f"[ScriptGen] Dropped {dropped_empty_lines} empty lines")
|
||||
|
||||
return PodcastScriptResponse(scenes=scenes)
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
logger.warning(f"[ScriptGen] ===== SCRIPT_GEN_END (took {duration_ms}ms) =====")
|
||||
|
||||
return PodcastScriptResponse(scenes=scenes)
|
||||
|
||||
251
backend/api/podcast/handlers/tavily_category_research.py
Normal file
251
backend/api/podcast/handlers/tavily_category_research.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Category Research Handlers
|
||||
|
||||
Research endpoints using Tavily or Exa for category-based topic discovery.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
from types import SimpleNamespace
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from services.research.tavily_service import TavilyService
|
||||
from services.blog_writer.research.exa_provider import ExaResearchProvider
|
||||
|
||||
router = APIRouter(prefix="/research", tags=["Podcast Category Research"])
|
||||
|
||||
CATEGORY_PROVIDER_MAP = {
|
||||
"news": "tavily",
|
||||
"finance": "tavily",
|
||||
"research-paper": "exa",
|
||||
"personal-site": "exa",
|
||||
}
|
||||
|
||||
EXA_CATEGORY_MAP = {
|
||||
"research-paper": "research paper",
|
||||
"personal-site": "personal site",
|
||||
}
|
||||
|
||||
|
||||
class CategoryResearchRequest(BaseModel):
|
||||
category: str
|
||||
keyword: Optional[str] = None
|
||||
max_results: Optional[int] = 8
|
||||
website_url: Optional[str] = None
|
||||
|
||||
|
||||
class CategoryTopic(BaseModel):
|
||||
title: str
|
||||
url: str
|
||||
snippet: str
|
||||
score: float
|
||||
favicon: Optional[str] = None
|
||||
|
||||
|
||||
class CategoryResearchResponse(BaseModel):
|
||||
success: bool
|
||||
category: str
|
||||
provider: str
|
||||
topics: List[CategoryTopic]
|
||||
query: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def _normalize_tavily_results(results: List[Dict]) -> List[CategoryTopic]:
|
||||
topics = []
|
||||
for item in results:
|
||||
topics.append(CategoryTopic(
|
||||
title=item.get("title", ""),
|
||||
url=item.get("url", ""),
|
||||
snippet=item.get("content", ""),
|
||||
score=item.get("score", 0.0),
|
||||
favicon=item.get("favicon"),
|
||||
))
|
||||
return topics
|
||||
|
||||
|
||||
def _normalize_exa_results(results: List[Dict], query: str) -> List[CategoryTopic]:
|
||||
topics = []
|
||||
for idx, item in enumerate(results):
|
||||
score = 1.0 - (idx * 0.1)
|
||||
topics.append(CategoryTopic(
|
||||
title=item.get("title", "") or f"Result {idx + 1}",
|
||||
url=item.get("url", ""),
|
||||
snippet=item.get("summary", "") or item.get("text", "") or "",
|
||||
score=max(0.5, score),
|
||||
favicon=None,
|
||||
))
|
||||
return topics
|
||||
|
||||
|
||||
async def _search_tavily(category: str, keyword: str, max_results: int) -> CategoryResearchResponse:
|
||||
logger.info(f"[CategoryResearch] Using Tavily for category={category}, keyword={keyword}")
|
||||
|
||||
try:
|
||||
tavily = TavilyService()
|
||||
result = await tavily.search(
|
||||
query=keyword,
|
||||
topic=category,
|
||||
search_depth="basic",
|
||||
max_results=max_results,
|
||||
include_favicon=True,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=result.get("error", "Tavily search failed")
|
||||
)
|
||||
|
||||
topics = _normalize_tavily_results(result.get("results", []))
|
||||
logger.info(f"[CategoryResearch] Tavily found {len(topics)} topics")
|
||||
|
||||
return CategoryResearchResponse(
|
||||
success=True,
|
||||
category=category,
|
||||
provider="tavily",
|
||||
topics=topics,
|
||||
query=keyword,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[CategoryResearch] Tavily error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
async def _search_exa(category: str, keyword: str, max_results: int, website_url: Optional[str] = None) -> CategoryResearchResponse:
|
||||
exa_category = EXA_CATEGORY_MAP.get(category, category)
|
||||
|
||||
logger.info(f"[CategoryResearch] Exa: category={category}, exa_category={exa_category}, keyword={keyword}, website_url={website_url}")
|
||||
|
||||
try:
|
||||
# Import exa directly for more control
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
exa_api_key = os.getenv("EXA_API_KEY")
|
||||
if not exa_api_key:
|
||||
raise HTTPException(status_code=500, detail="EXA_API_KEY not configured")
|
||||
|
||||
from exa_py import Exa
|
||||
exa = Exa(exa_api_key)
|
||||
logger.info(f"[CategoryResearch] Exa client initialized")
|
||||
|
||||
# Build search parameters
|
||||
search_params = {
|
||||
"num_results": max_results,
|
||||
"category": exa_category,
|
||||
}
|
||||
|
||||
# For personal-site, extract domain from URL if provided
|
||||
include_domains = None
|
||||
if category == "personal-site" and website_url:
|
||||
try:
|
||||
parsed = urlparse(website_url)
|
||||
if parsed.netloc:
|
||||
include_domains = [parsed.netloc]
|
||||
logger.info(f"[CategoryResearch] Personal site - limiting to domain: {parsed.netloc}")
|
||||
elif parsed.path and "." in parsed.path:
|
||||
# Could be domain without protocol
|
||||
include_domains = [parsed.path]
|
||||
logger.info(f"[CategoryResearch] Personal site - using as domain: {parsed.path}")
|
||||
except Exception as url_err:
|
||||
logger.warning(f"[CategoryResearch] Failed to parse website_url: {url_err}")
|
||||
|
||||
logger.info(f"[CategoryResearch] Calling Exa with params: {search_params}, include_domains={include_domains}")
|
||||
|
||||
# Make the search call
|
||||
results = exa.search_and_contents(
|
||||
query=keyword,
|
||||
type="auto" if category != "personal-site" else "neural",
|
||||
num_results=max_results,
|
||||
category=exa_category,
|
||||
text=True,
|
||||
summary=True,
|
||||
include_domains=include_domains,
|
||||
)
|
||||
|
||||
logger.info(f"[CategoryResearch] Exa search completed, got results")
|
||||
|
||||
# Transform results to our format
|
||||
topics = []
|
||||
if results and hasattr(results, 'results'):
|
||||
for item in results.results:
|
||||
title = getattr(item, 'title', 'Untitled')
|
||||
url = getattr(item, 'url', '')
|
||||
snippet = getattr(item, 'summary', '') or getattr(item, 'text', '') or ''
|
||||
score = 0.8 # Default score for Exa results
|
||||
|
||||
topics.append(CategoryTopic(
|
||||
title=title,
|
||||
url=url,
|
||||
snippet=snippet[:300] if snippet else '',
|
||||
score=score,
|
||||
favicon=None,
|
||||
))
|
||||
|
||||
logger.info(f"[CategoryResearch] Exa found {len(topics)} topics")
|
||||
|
||||
return CategoryResearchResponse(
|
||||
success=True,
|
||||
category=category,
|
||||
provider="exa",
|
||||
topics=topics,
|
||||
query=keyword,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"[CategoryResearch] Exa error: {type(e).__name__}: {e}")
|
||||
logger.error(f"[CategoryResearch] Stack: {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"Exa search failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/tavily-category", response_model=CategoryResearchResponse)
|
||||
async def research_by_category(
|
||||
request: CategoryResearchRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Research topics by category using Tavily or Exa.
|
||||
|
||||
Categories:
|
||||
- news, finance: Uses Tavily
|
||||
- research-paper, personal-site: Uses Exa
|
||||
"""
|
||||
category = request.category.lower()
|
||||
valid_categories = list(CATEGORY_PROVIDER_MAP.keys())
|
||||
|
||||
logger.info(f"[CategoryResearch] Full request payload: category={request.category}, keyword={request.keyword}, website_url={request.website_url}")
|
||||
|
||||
if category not in valid_categories:
|
||||
logger.error(f"[CategoryResearch] Invalid category: {category}, valid: {valid_categories}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Category must be one of: {', '.join(valid_categories)}"
|
||||
)
|
||||
|
||||
keyword = request.keyword or category
|
||||
max_results = min(max(request.max_results or 8, 5), 10)
|
||||
website_url = request.website_url
|
||||
|
||||
logger.info(f"[CategoryResearch] Processing: category={category}, keyword={keyword}, max_results={max_results}, website_url={website_url}")
|
||||
|
||||
provider = CATEGORY_PROVIDER_MAP.get(category, "tavily")
|
||||
logger.info(f"[CategoryResearch] Selected provider: {provider} for category: {category}")
|
||||
|
||||
try:
|
||||
if provider == "tavily":
|
||||
return await _search_tavily(category, keyword, max_results)
|
||||
elif provider == "exa":
|
||||
return await _search_exa(category, keyword, max_results, website_url)
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Unknown provider")
|
||||
except Exception as e:
|
||||
logger.error(f"[CategoryResearch] Outer error: {type(e).__name__}: {e}", exc_info=True)
|
||||
raise
|
||||
92
backend/api/podcast/handlers/trends.py
Normal file
92
backend/api/podcast/handlers/trends.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Podcast Trends Handler
|
||||
|
||||
Endpoints for fetching Google Trends data relevant to podcast topics.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from loguru import logger
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/trends", tags=["Podcast Trends"])
|
||||
|
||||
|
||||
class PodcastTrendsRequest(BaseModel):
|
||||
keywords: List[str] = Field(..., min_length=1, max_length=5, description="1-5 keywords to analyze")
|
||||
timeframe: str = Field(default="today 12-m", description="Timeframe: 'today 3-m', 'today 12-m', 'today 5-y', 'all'")
|
||||
geo: str = Field(default="US", description="Country code: 'US', 'GB', 'IN', etc.")
|
||||
source: str = Field(default="web", description="Data source: 'web' (Google), 'podcast' (YouTube)")
|
||||
|
||||
|
||||
class PodcastTrendsResponse(BaseModel):
|
||||
success: bool
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("", response_model=PodcastTrendsResponse)
|
||||
async def get_podcast_trends(
|
||||
request: PodcastTrendsRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Fetch Google Trends data for podcast topic keywords."""
|
||||
user_id = current_user.get("user_id") or current_user.get("id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User ID not found")
|
||||
|
||||
try:
|
||||
from services.research.trends import GoogleTrendsService
|
||||
except (ImportError, RuntimeError) as e:
|
||||
logger.error(f"[Podcast Trends] GoogleTrendsService unavailable: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Google Trends service is currently unavailable. Please try again later."
|
||||
)
|
||||
|
||||
try:
|
||||
service = GoogleTrendsService()
|
||||
# Map 'source' to 'gprop' - 'podcast' uses YouTube for video/podcast relevance
|
||||
gprop_map = {"": "", "web": "", "podcast": "youtube", "news": "news", "images": "images", "shopping": "froogle"}
|
||||
gprop = gprop_map.get(request.source, "")
|
||||
|
||||
result = await service.analyze_trends(
|
||||
keywords=request.keywords,
|
||||
timeframe=request.timeframe,
|
||||
geo=request.geo,
|
||||
gprop=gprop,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
has_error = result.get("error")
|
||||
has_data = (
|
||||
len(result.get("interest_over_time", [])) > 0
|
||||
or len(result.get("interest_by_region", [])) > 0
|
||||
or len(result.get("related_topics", {}).get("top", [])) > 0
|
||||
or len(result.get("related_topics", {}).get("rising", [])) > 0
|
||||
or len(result.get("related_queries", {}).get("top", [])) > 0
|
||||
or len(result.get("related_queries", {}).get("rising", [])) > 0
|
||||
)
|
||||
|
||||
# Return error if: has error OR no data (meaning blocked/empty)
|
||||
if has_error and not has_data:
|
||||
error_msg = result.get("error", "")
|
||||
logger.warning(f"[Trends] No data or error: {error_msg[:100]}")
|
||||
return PodcastTrendsResponse(success=False, data=result, error=error_msg or "No trends data available. Google may be blocking requests.")
|
||||
|
||||
# Even if no error but empty data - return error
|
||||
if not has_data:
|
||||
logger.warning("[Trends] Empty data returned")
|
||||
return PodcastTrendsResponse(success=False, data=result, error="No trends data available. Please try different keywords.")
|
||||
|
||||
return PodcastTrendsResponse(success=True, data=result)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"[Podcast Trends] Error fetching trends for {request.keywords}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to fetch trends data: {str(e)}"
|
||||
)
|
||||
@@ -321,7 +321,7 @@ async def generate_podcast_video(
|
||||
|
||||
# Load image bytes (scene image is required for video generation)
|
||||
if body.avatar_image_url:
|
||||
image_bytes = load_podcast_image_bytes(body.avatar_image_url)
|
||||
image_bytes = load_podcast_image_bytes(body.avatar_image_url, user_id=user_id)
|
||||
else:
|
||||
# Scene-specific image should be generated before video generation
|
||||
raise HTTPException(
|
||||
@@ -332,7 +332,7 @@ async def generate_podcast_video(
|
||||
mask_image_bytes = None
|
||||
if body.mask_image_url:
|
||||
try:
|
||||
mask_image_bytes = load_podcast_image_bytes(body.mask_image_url)
|
||||
mask_image_bytes = load_podcast_image_bytes(body.mask_image_url, user_id=user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"[Podcast] Failed to load mask image: {e}")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -73,12 +73,21 @@ class PodcastAnalyzeResponse(BaseModel):
|
||||
bible: Optional[Dict[str, Any]] = None
|
||||
avatar_url: Optional[str] = None
|
||||
avatar_prompt: Optional[str] = None
|
||||
estimate: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class PodcastEnhanceIdeaRequest(BaseModel):
|
||||
"""Request model for enhancing a podcast idea with AI."""
|
||||
idea: str = Field(..., description="The raw podcast idea or keywords")
|
||||
bible: Optional[Dict[str, Any]] = Field(None, description="Optional Podcast Bible for context")
|
||||
website_data: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Optional website extraction data for enriched context (title, summary, highlights, subpages, url)"
|
||||
)
|
||||
topic_context: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Optional category research context (category, topics, selected_topic)"
|
||||
)
|
||||
|
||||
|
||||
class PodcastEnhanceIdeaResponse(BaseModel):
|
||||
@@ -96,6 +105,7 @@ class PodcastScriptRequest(BaseModel):
|
||||
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
|
||||
outline: Optional[Dict[str, Any]] = Field(None, description="The refined episode outline to follow")
|
||||
analysis: Optional[Dict[str, Any]] = Field(None, description="The full analysis context (audience, keywords, etc.)")
|
||||
podcast_mode: Optional[str] = Field(default="video_only", description="Podcast mode: audio_only, video_only, or audio_video")
|
||||
|
||||
|
||||
class PodcastSceneLine(BaseModel):
|
||||
@@ -104,6 +114,7 @@ class PodcastSceneLine(BaseModel):
|
||||
emphasis: Optional[bool] = False
|
||||
id: Optional[str] = None # Optional line ID for frontend tracking
|
||||
usedFactIds: Optional[List[str]] = None # Facts referenced in this line
|
||||
ttsHints: Optional[List[str]] = None # Optional TTS hints, e.g. pause_300ms, smile, emphasize_data
|
||||
|
||||
|
||||
class PodcastScene(BaseModel):
|
||||
@@ -116,6 +127,7 @@ class PodcastScene(BaseModel):
|
||||
imageUrl: Optional[str] = None # Generated image URL for video generation
|
||||
audioUrl: Optional[str] = None # Generated audio URL for this scene
|
||||
imagePrompt: Optional[str] = None # Original image generation prompt for video context
|
||||
chart_data: Optional[Dict[str, Any]] = None # Optional chart mapping for B-roll scenes
|
||||
|
||||
|
||||
class PodcastExaConfig(BaseModel):
|
||||
@@ -205,6 +217,7 @@ class PodcastExaResearchResponse(BaseModel):
|
||||
mapped_angles: List[Dict[str, Any]] = [] # Content angles for the episode
|
||||
expert_quotes: List[Dict[str, Any]] = [] # Expert quotes from research
|
||||
listener_cta_suggestions: List[str] = [] # CTA suggestions
|
||||
estimate: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class PodcastScriptResponse(BaseModel):
|
||||
@@ -218,6 +231,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
|
||||
@@ -462,3 +478,59 @@ class VoiceCloneResult(BaseModel):
|
||||
file_size: int
|
||||
task_id: str
|
||||
status: str = "completed"
|
||||
|
||||
|
||||
class ExtractUrlRequest(BaseModel):
|
||||
"""Request to extract content from a URL using Exa."""
|
||||
url: str = Field(..., description="URL to extract content from")
|
||||
|
||||
|
||||
class ExtractUrlResponse(BaseModel):
|
||||
"""Response with extracted content from URL."""
|
||||
success: bool
|
||||
title: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
highlights: Optional[List[str]] = Field(default_factory=list, description="Key highlights from the content")
|
||||
url: str
|
||||
image: Optional[str] = None
|
||||
favicon: Optional[str] = None
|
||||
subpages: Optional[List[Dict[str, Any]]] = Field(default_factory=list, description="Subpages with their own content")
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class WebsiteAnalysisRequest(BaseModel):
|
||||
"""Request to save user's website analysis."""
|
||||
website_url: str = Field(..., description="The website URL")
|
||||
exa_content: Dict[str, Any] = Field(default_factory=dict, description="Exa extracted content")
|
||||
|
||||
|
||||
class WebsiteAnalysisResponse(BaseModel):
|
||||
"""Response for website analysis."""
|
||||
success: bool
|
||||
website_url: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class PodcastPreEstimateRequest(BaseModel):
|
||||
"""Request model for pre-analysis cost estimate."""
|
||||
duration: int = Field(default=10, description="Target duration in minutes")
|
||||
speakers: int = Field(default=1, description="Number of speakers")
|
||||
query_count: int = Field(default=3, description="Number of research queries")
|
||||
podcast_mode: str = Field(default="audio_video", description="Podcast mode: audio_only, video_only, or audio_video")
|
||||
# Optional model overrides for cost estimation
|
||||
gemini_model: Optional[str] = Field(default=None, description="LLM model: gemini-2.5-flash, gemini-1.5-flash, etc.")
|
||||
audio_tts_model: Optional[str] = Field(default=None, description="Audio TTS model: minimax/speech-02-hd")
|
||||
voice_clone_engine: Optional[str] = Field(default=None, description="Voice clone engine: qwen3, cosyvoice, minimax")
|
||||
image_model: Optional[str] = Field(default=None, description="Image model: qwen-image, ideogram-v3-turbo")
|
||||
video_model: Optional[str] = Field(default=None, description="Video model: wan-2.5, kling-v2.5-turbo-std-5s, wavespeed-ai/infinitetalk")
|
||||
|
||||
|
||||
class PodcastPreEstimateResponse(BaseModel):
|
||||
"""Response model for pre-analysis cost estimate."""
|
||||
estimate: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
pricing_available: bool = Field(default=False, description="Whether pricing data is available in DB")
|
||||
debug: Optional[Dict[str, Any]] = Field(default=None, description="Debug info: pricing rows count, providers")
|
||||
|
||||
24
backend/api/podcast/prompts/__init__.py
Normal file
24
backend/api/podcast/prompts/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Prompts module for podcast topic enhancement.
|
||||
"""
|
||||
|
||||
from .website_enhance_prompts import (
|
||||
get_enhance_topic_prompt,
|
||||
format_website_context,
|
||||
STANDARD_ENHANCE_PROMPT,
|
||||
WEBSITE_AWARE_ENHANCE_PROMPT,
|
||||
)
|
||||
|
||||
from services.podcast_context_builder import (
|
||||
PodcastContextBuilder,
|
||||
context_builder,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_enhance_topic_prompt",
|
||||
"format_website_context",
|
||||
"STANDARD_ENHANCE_PROMPT",
|
||||
"WEBSITE_AWARE_ENHANCE_PROMPT",
|
||||
"PodcastContextBuilder",
|
||||
"context_builder",
|
||||
]
|
||||
187
backend/api/podcast/prompts/website_enhance_prompts.py
Normal file
187
backend/api/podcast/prompts/website_enhance_prompts.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Website-aware prompts for podcast topic enhancement.
|
||||
|
||||
This module provides prompts for enhancing podcast topics with optional
|
||||
website extraction data for richer context.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from string import Template
|
||||
|
||||
|
||||
# Standard prompt for when no website data is available
|
||||
STANDARD_ENHANCE_PROMPT = Template("""">You are a creative podcast producer. Generate 3 distinct, compelling podcast episode concepts from the raw idea.
|
||||
|
||||
${bible_context}
|
||||
|
||||
RAW IDEA/KEYWORDS: "$idea"
|
||||
|
||||
TASK:
|
||||
Generate 3 different enhanced versions, each with a unique angle:
|
||||
1. Professional & Expert-led angle (focus on authority, insights, and expertise)
|
||||
2. Storytelling & Human interest angle (focus on narratives, emotions, and personal connections)
|
||||
3. Trendy & Contemporary angle (focus on current trends, modern perspectives, and relevance)
|
||||
|
||||
Each version should be 2-3 sentences, audience-focused, and align with host persona if provided.
|
||||
|
||||
Return JSON with:
|
||||
- enhanced_ideas: array of 3 strings, each string being a complete episode pitch (NOT objects, just plain strings)
|
||||
- rationales: array of 3 strings explaining the approach for each version
|
||||
|
||||
IMPORTANT: enhanced_ideas must be an array of plain strings, NOT objects. Example:
|
||||
{
|
||||
"enhanced_ideas": [
|
||||
"Your expert guide to AI advancement: A practical look at how AI is transforming industries...",
|
||||
"The human stories behind AI innovation: From Silicon Valley to your daily life...",
|
||||
"AI in 2026: What's trending and what's next in artificial intelligence..."
|
||||
],
|
||||
"rationales": [
|
||||
"Professional approach focusing on expertise and authority",
|
||||
"Storytelling approach emphasizing human connection",
|
||||
"Contemporary approach highlighting current relevance"
|
||||
]
|
||||
}
|
||||
""")
|
||||
|
||||
|
||||
# Website-aware prompt for when website data is available
|
||||
WEBSITE_AWARE_ENHANCE_PROMPT = Template("""">You are a creative podcast producer. Generate 3 distinct, compelling podcast episode concepts from the raw idea, enriched with website content analysis.
|
||||
|
||||
${bible_context}
|
||||
|
||||
WEBSITE CONTENT ANALYSIS:
|
||||
${website_context}
|
||||
|
||||
RAW IDEA/KEYWORDS: "$idea"
|
||||
|
||||
TASK:
|
||||
Generate 3 different enhanced versions, each with a unique angle, that INCORPORATE the website content context:
|
||||
1. Professional & Expert-led angle (focus on authority, insights, and expertise from the website)
|
||||
2. Storytelling & Human interest angle (focus on narratives, emotions, and personal connections tied to the brand)
|
||||
3. Trendy & Contemporary angle (focus on current trends, modern perspectives, and relevance leveraging the site's focus areas)
|
||||
|
||||
Each version should:
|
||||
- Be 2-3 sentences
|
||||
- Reference specific elements from the website content when relevant
|
||||
- Be audience-focused and align with host persona if provided
|
||||
- NOT just repeat the website summary - create fresh podcast angles
|
||||
|
||||
Return JSON with:
|
||||
- enhanced_ideas: array of 3 strings, each string being a complete episode pitch (NOT objects, just plain strings)
|
||||
- rationales: array of 3 strings explaining the approach for each version
|
||||
|
||||
IMPORTANT: enhanced_ideas must be an array of plain strings, NOT objects. Example:
|
||||
{
|
||||
"enhanced_ideas": [
|
||||
"Your expert guide to AI advancement: A practical look at how AI is transforming industries...",
|
||||
"The human stories behind AI innovation: From Silicon Valley to your daily life...",
|
||||
"AI in 2026: What's trending and what's next in artificial intelligence..."
|
||||
],
|
||||
"rationales": [
|
||||
"Professional approach focusing on expertise and authority",
|
||||
"Storytelling approach emphasizing human connection",
|
||||
"Contemporary approach highlighting current relevance"
|
||||
]
|
||||
}
|
||||
""")
|
||||
|
||||
|
||||
def get_enhance_topic_prompt(
|
||||
idea: str,
|
||||
bible_context: str = "",
|
||||
website_data: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Returns the appropriate prompt based on available context.
|
||||
|
||||
Args:
|
||||
idea: The raw podcast idea or keywords
|
||||
bible_context: Optional Podcast Bible context string
|
||||
website_data: Optional website extraction data
|
||||
|
||||
Returns:
|
||||
Formatted prompt string with appropriate context
|
||||
"""
|
||||
# Build bible context section
|
||||
bible_section = f"USER PERSONALIZATION CONTEXT (Podcast Bible):\n{bible_context}\n" if bible_context else ""
|
||||
|
||||
if website_data:
|
||||
# Build website context section
|
||||
website_context_parts = []
|
||||
if website_data.get('url'):
|
||||
website_context_parts.append(f"Source: {website_data.get('url')}")
|
||||
if website_data.get('title'):
|
||||
website_context_parts.append(f"Company/Organization: {website_data.get('title')}")
|
||||
if website_data.get('summary'):
|
||||
website_context_parts.append(f"About: {website_data.get('summary')}")
|
||||
if website_data.get('highlights'):
|
||||
highlights_str = ', '.join(website_data.get('highlights', [])[:3])
|
||||
website_context_parts.append(f"Key Highlights: {highlights_str}")
|
||||
if website_data.get('subpages'):
|
||||
subpages_str = ', '.join([
|
||||
sp.get('title', sp.get('url', ''))
|
||||
for sp in website_data.get('subpages', [])[:3]
|
||||
])
|
||||
website_context_parts.append(f"Subpages: {subpages_str}")
|
||||
|
||||
website_context_str = "\n".join(website_context_parts)
|
||||
|
||||
return WEBSITE_AWARE_ENHANCE_PROMPT.substitute(
|
||||
idea=idea,
|
||||
bible_context=bible_section,
|
||||
website_context=website_context_str
|
||||
)
|
||||
else:
|
||||
return STANDARD_ENHANCE_PROMPT.substitute(
|
||||
idea=idea,
|
||||
bible_context=bible_section
|
||||
)
|
||||
|
||||
|
||||
def format_website_context(website_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Format website data for inclusion in progress messages.
|
||||
|
||||
Args:
|
||||
website_data: Website extraction data
|
||||
|
||||
Returns:
|
||||
Formatted string describing what's being used
|
||||
"""
|
||||
parts = []
|
||||
|
||||
if website_data.get('title'):
|
||||
parts.append(f"• {website_data['title']}")
|
||||
|
||||
if website_data.get('summary'):
|
||||
summary_preview = website_data['summary'][:100]
|
||||
parts.append(f"• Summary: {summary_preview}...")
|
||||
|
||||
if website_data.get('highlights'):
|
||||
parts.append(f"• {len(website_data['highlights'])} key highlights")
|
||||
|
||||
if website_data.get('subpages'):
|
||||
parts.append(f"• {len(website_data['subpages'])} subpages analyzed")
|
||||
|
||||
if website_data.get('url'):
|
||||
parts.append(f"• Source: {website_data['url']}")
|
||||
|
||||
return "\n".join(parts) if parts else "Basic website analysis"
|
||||
|
||||
if website_data.get('title'):
|
||||
parts.append(f"• {website_data['title']}")
|
||||
|
||||
if website_data.get('summary'):
|
||||
summary_preview = website_data['summary'][:100]
|
||||
parts.append(f"• Summary: {summary_preview}...")
|
||||
|
||||
if website_data.get('highlights'):
|
||||
parts.append(f"• {len(website_data['highlights'])} key highlights")
|
||||
|
||||
if website_data.get('subpages'):
|
||||
parts.append(f"• {len(website_data['subpages'])} subpages analyzed")
|
||||
|
||||
if website_data.get('url'):
|
||||
parts.append(f"• Source: {website_data['url']}")
|
||||
|
||||
return "\n".join(parts) if parts else "Basic website analysis"
|
||||
@@ -12,7 +12,7 @@ from api.story_writer.utils.auth import require_authenticated_user
|
||||
from api.story_writer.task_manager import task_manager
|
||||
|
||||
# Import all handler routers
|
||||
from .handlers import projects, analysis, research, script, audio, images, video, avatar, dubbing
|
||||
from .handlers import projects, analysis, research, script, audio, images, video, avatar, dubbing, broll, trends, tavily_category_research
|
||||
|
||||
# Create main router
|
||||
router = APIRouter(prefix="/api/podcast", tags=["Podcast Maker"])
|
||||
@@ -27,6 +27,9 @@ router.include_router(images.router)
|
||||
router.include_router(video.router)
|
||||
router.include_router(avatar.router)
|
||||
router.include_router(dubbing.router)
|
||||
router.include_router(broll.router)
|
||||
router.include_router(trends.router)
|
||||
router.include_router(tavily_category_research.router)
|
||||
|
||||
|
||||
@router.get("/task/{task_id}/status")
|
||||
|
||||
@@ -67,15 +67,32 @@ def load_podcast_audio_bytes(audio_url: str, user_id: str | None = None) -> byte
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load audio: {str(exc)}")
|
||||
|
||||
|
||||
def load_podcast_image_bytes(image_url: str) -> bytes:
|
||||
"""Load podcast image bytes from URL. Uses centralized media loader."""
|
||||
def load_podcast_image_bytes(image_url: str, user_id: str | None = None) -> bytes:
|
||||
"""Load podcast image bytes from URL. Resolves from workspace first."""
|
||||
if not image_url:
|
||||
raise HTTPException(status_code=400, detail="Image URL is required")
|
||||
|
||||
logger.info(f"[Podcast] Loading image from URL: {image_url}")
|
||||
|
||||
try:
|
||||
# REUSE: Use centralized media loader which handles cross-module lookups
|
||||
# Extract filename from URL path
|
||||
prefix = "/api/podcast/images/"
|
||||
if prefix in image_url:
|
||||
filename = image_url.split(prefix, 1)[1].split("?", 1)[0].strip()
|
||||
# Handle subdirectories like avatars/
|
||||
subdir = None
|
||||
if "/" in filename:
|
||||
subdir_part = filename.rsplit("/", 1)[0]
|
||||
subdir = Path(subdir_part)
|
||||
filename = filename.rsplit("/", 1)[1]
|
||||
|
||||
try:
|
||||
image_path = _resolve_podcast_media_file(filename, "image", user_id, subdir=subdir)
|
||||
return image_path.read_bytes()
|
||||
except HTTPException:
|
||||
pass # Fall through to centralized loader
|
||||
|
||||
# Fall back to centralized media loader
|
||||
image_bytes = load_media_bytes(image_url)
|
||||
|
||||
if not image_bytes:
|
||||
|
||||
@@ -52,7 +52,7 @@ def is_podcast_only_demo_mode() -> bool:
|
||||
env_val = os.getenv("ALWRITY_ENABLED_FEATURES", "all")
|
||||
enabled = get_enabled_features()
|
||||
result = "podcast" in enabled and "all" not in enabled
|
||||
print(f"[DEBUG] is_podcast_only_demo_mode: ALWRITY_ENABLED_FEATURES={env_val}, enabled={enabled}, result={result}", flush=True)
|
||||
# Removed debug print - too verbose during startup
|
||||
return result
|
||||
|
||||
|
||||
@@ -424,12 +424,30 @@ if PODCAST_ONLY_DEMO_MODE:
|
||||
# In podcast-only mode, include only podcast-enabled routers from core registry
|
||||
from alwrity_utils.router_manager import CORE_ROUTER_REGISTRY
|
||||
podcast_routers = [r for r in CORE_ROUTER_REGISTRY if "podcast" in r.get("features", set())]
|
||||
for entry in podcast_routers:
|
||||
logger.info(f"[PODCAST-ONLY] Found {len(podcast_routers)} podcast routers: {[r['name'] for r in podcast_routers]}")
|
||||
|
||||
# Try to include step4_assets for voice cloning (may fail if nltk not installed)
|
||||
step4_entry = next((r for r in CORE_ROUTER_REGISTRY if r.get("name") == "step4_assets"), None)
|
||||
if step4_entry:
|
||||
try:
|
||||
logger.info(f"[PODCAST-ONLY] Attempting to load step4_assets for voice cloning")
|
||||
router = router_manager._load_router_from_registry(step4_entry)
|
||||
router_manager.include_router_safely(router, step4_entry["name"], step4_entry.get("include_kwargs"))
|
||||
except ImportError as e:
|
||||
logger.warning(f"[PODCAST-ONLY] Skipping step4_assets (missing optional dependency): {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[PODCAST-ONLY] Failed to mount step4_assets: {e}")
|
||||
|
||||
# Load other podcast routers
|
||||
for entry in podcast_routers:
|
||||
if entry.get("name") == "step4_assets":
|
||||
continue # Already loaded above
|
||||
try:
|
||||
logger.info(f"[PODCAST-ONLY] Loading router: {entry['name']}")
|
||||
router = router_manager._load_router_from_registry(entry)
|
||||
router_manager.include_router_safely(router, entry["name"], entry.get("include_kwargs"))
|
||||
except Exception as e:
|
||||
logger.warning(f"{entry['name']} router not mounted: {e}")
|
||||
logger.error(f"[PODCAST-ONLY] Failed to mount {entry.get('name', 'unknown')}: {e}")
|
||||
router_group_status["modular_core"] = {
|
||||
"mounted": True,
|
||||
"reason": "Podcast routers only in podcast-only mode",
|
||||
@@ -631,6 +649,7 @@ else:
|
||||
|
||||
# Include Podcast Maker router (always needed for podcast mode)
|
||||
from api.podcast.router import router as podcast_router
|
||||
logger.info(f"[PODCAST] Including podcast_router with prefixes: {podcast_router.routes}")
|
||||
app.include_router(podcast_router)
|
||||
router_group_status["podcast_maker"] = {
|
||||
"mounted": True,
|
||||
@@ -693,6 +712,9 @@ async def startup_event():
|
||||
try:
|
||||
_log_memory_usage()
|
||||
|
||||
# Note: Pricing is initialized per-user in services/database.py:init_user_database()
|
||||
# which runs on first database access for each user. No global seeding needed at startup.
|
||||
|
||||
# Skip startup health checks in podcast-only mode to avoid unnecessary DB errors
|
||||
if not is_podcast_only_demo_mode():
|
||||
startup_report = run_startup_health_routine(app)
|
||||
|
||||
@@ -45,6 +45,9 @@ class PodcastProject(Base):
|
||||
knobs = Column(JSON, nullable=True) # Knobs settings
|
||||
research_provider = Column(String(50), nullable=True, default="google") # Research provider
|
||||
|
||||
# Project-specific topic context (category research, selected topics)
|
||||
topic_context = Column(JSON, nullable=True) # { category: "news"|"finance", topics: [...], selected_topic: {...} }
|
||||
|
||||
# UI state
|
||||
show_script_editor = Column(Boolean, default=False)
|
||||
show_render_queue = Column(Boolean, default=False)
|
||||
|
||||
@@ -80,6 +80,7 @@ class SubscriptionPlan(Base):
|
||||
video_calls_limit = Column(Integer, default=0) # AI video generation
|
||||
image_edit_calls_limit = Column(Integer, default=0) # AI image editing
|
||||
audio_calls_limit = Column(Integer, default=0) # AI audio generation (text-to-speech)
|
||||
wavespeed_calls_limit = Column(Integer, default=0) # WaveSpeed API calls (LLM + TTS + video + image)
|
||||
|
||||
# Token Limits (for LLM providers)
|
||||
gemini_tokens_limit = Column(Integer, default=0)
|
||||
|
||||
@@ -47,6 +47,7 @@ pandas>=2.0.0
|
||||
|
||||
# Image/media for podcast
|
||||
Pillow>=10.0.0
|
||||
matplotlib>=3.7.0
|
||||
huggingface_hub>=1.1.4
|
||||
|
||||
# TTS for podcast
|
||||
|
||||
@@ -45,6 +45,7 @@ numpy>=1.24.0
|
||||
|
||||
# Image/media for podcast
|
||||
Pillow>=10.0.0
|
||||
matplotlib>=3.8.0
|
||||
huggingface_hub>=1.1.4
|
||||
|
||||
# TTS for podcast
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
"""
|
||||
Initialize Alpha Tester Subscription Tiers
|
||||
Creates subscription plans for alpha testing with appropriate limits.
|
||||
|
||||
NOTE: Pricing is seeded via PricingService.initialize_default_pricing()
|
||||
which runs in services/database.py:init_user_database()
|
||||
NOT via this script.
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -10,7 +14,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from models.subscription_models import (
|
||||
SubscriptionPlan, SubscriptionTier, APIProviderPricing, APIProvider
|
||||
SubscriptionPlan, SubscriptionTier
|
||||
)
|
||||
from services.database import get_db_session
|
||||
from datetime import datetime
|
||||
@@ -24,7 +28,7 @@ def create_alpha_subscription_tiers():
|
||||
|
||||
db = get_db_session()
|
||||
if not db:
|
||||
logger.error("❌ Could not get database session")
|
||||
logger.error("Could not get database session")
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -38,12 +42,12 @@ def create_alpha_subscription_tiers():
|
||||
"description": "Free tier for alpha testing - Limited usage",
|
||||
"features": ["blog_writer", "basic_seo", "content_planning"],
|
||||
"limits": {
|
||||
"gemini_calls_limit": 50, # 50 calls per day
|
||||
"gemini_tokens_limit": 10000, # 10k tokens per day
|
||||
"tavily_calls_limit": 20, # 20 searches per day
|
||||
"serper_calls_limit": 10, # 10 SEO searches per day
|
||||
"stability_calls_limit": 5, # 5 images per day
|
||||
"monthly_cost_limit": 5.0 # $5 monthly limit
|
||||
"gemini_calls_limit": 50,
|
||||
"gemini_tokens_limit": 10000,
|
||||
"tavily_calls_limit": 20,
|
||||
"serper_calls_limit": 10,
|
||||
"stability_calls_limit": 5,
|
||||
"monthly_cost_limit": 5.0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -54,12 +58,12 @@ def create_alpha_subscription_tiers():
|
||||
"description": "Basic alpha tier - Moderate usage for testing",
|
||||
"features": ["blog_writer", "seo_analysis", "content_planning", "strategy_copilot"],
|
||||
"limits": {
|
||||
"gemini_calls_limit": 200, # 200 calls per day
|
||||
"gemini_tokens_limit": 50000, # 50k tokens per day
|
||||
"tavily_calls_limit": 100, # 100 searches per day
|
||||
"serper_calls_limit": 50, # 50 SEO searches per day
|
||||
"stability_calls_limit": 25, # 25 images per day
|
||||
"monthly_cost_limit": 25.0 # $25 monthly limit
|
||||
"gemini_calls_limit": 200,
|
||||
"gemini_tokens_limit": 50000,
|
||||
"tavily_calls_limit": 100,
|
||||
"serper_calls_limit": 50,
|
||||
"stability_calls_limit": 25,
|
||||
"monthly_cost_limit": 25.0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -70,12 +74,12 @@ def create_alpha_subscription_tiers():
|
||||
"description": "Pro alpha tier - High usage for power users",
|
||||
"features": ["blog_writer", "seo_analysis", "content_planning", "strategy_copilot", "advanced_analytics"],
|
||||
"limits": {
|
||||
"gemini_calls_limit": 500, # 500 calls per day
|
||||
"gemini_tokens_limit": 150000, # 150k tokens per day
|
||||
"tavily_calls_limit": 300, # 300 searches per day
|
||||
"serper_calls_limit": 150, # 150 SEO searches per day
|
||||
"stability_calls_limit": 100, # 100 images per day
|
||||
"monthly_cost_limit": 100.0 # $100 monthly limit
|
||||
"gemini_calls_limit": 500,
|
||||
"gemini_tokens_limit": 150000,
|
||||
"tavily_calls_limit": 300,
|
||||
"serper_calls_limit": 150,
|
||||
"stability_calls_limit": 100,
|
||||
"monthly_cost_limit": 100.0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -86,34 +90,31 @@ def create_alpha_subscription_tiers():
|
||||
"description": "Enterprise alpha tier - Unlimited usage for enterprise testing",
|
||||
"features": ["blog_writer", "seo_analysis", "content_planning", "strategy_copilot", "advanced_analytics", "custom_integrations"],
|
||||
"limits": {
|
||||
"gemini_calls_limit": 0, # Unlimited calls
|
||||
"gemini_tokens_limit": 0, # Unlimited tokens
|
||||
"tavily_calls_limit": 0, # Unlimited searches
|
||||
"serper_calls_limit": 0, # Unlimited SEO searches
|
||||
"stability_calls_limit": 0, # Unlimited images
|
||||
"monthly_cost_limit": 500.0 # $500 monthly limit
|
||||
"gemini_calls_limit": 0,
|
||||
"gemini_tokens_limit": 0,
|
||||
"tavily_calls_limit": 0,
|
||||
"serper_calls_limit": 0,
|
||||
"stability_calls_limit": 0,
|
||||
"monthly_cost_limit": 500.0
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
# Create subscription plans
|
||||
for tier_data in alpha_tiers:
|
||||
# Check if plan already exists
|
||||
existing_plan = db.query(SubscriptionPlan).filter(
|
||||
SubscriptionPlan.name == tier_data["name"]
|
||||
).first()
|
||||
|
||||
if existing_plan:
|
||||
logger.info(f"✅ Plan '{tier_data['name']}' already exists, updating...")
|
||||
# Update existing plan
|
||||
logger.info(f"Plan '{tier_data['name']}' already exists, updating...")
|
||||
for key, value in tier_data["limits"].items():
|
||||
setattr(existing_plan, key, value)
|
||||
existing_plan.description = tier_data["description"]
|
||||
existing_plan.features = tier_data["features"]
|
||||
existing_plan.updated_at = datetime.utcnow()
|
||||
else:
|
||||
logger.info(f"🆕 Creating new plan: {tier_data['name']}")
|
||||
# Create new plan
|
||||
logger.info(f"Creating new plan: {tier_data['name']}")
|
||||
plan = SubscriptionPlan(
|
||||
name=tier_data["name"],
|
||||
tier=tier_data["tier"],
|
||||
@@ -126,106 +127,17 @@ def create_alpha_subscription_tiers():
|
||||
db.add(plan)
|
||||
|
||||
db.commit()
|
||||
logger.info("✅ Alpha subscription tiers created/updated successfully!")
|
||||
|
||||
# Create API provider pricing
|
||||
create_api_pricing(db)
|
||||
logger.info("Alpha subscription tiers created/updated successfully!")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error creating alpha subscription tiers: {e}")
|
||||
logger.error(f"Error creating alpha subscription tiers: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def create_api_pricing(db: Session):
|
||||
"""Create API provider pricing configuration."""
|
||||
|
||||
try:
|
||||
# Gemini pricing (based on current Google AI pricing)
|
||||
gemini_pricing = [
|
||||
{
|
||||
"model_name": "gemini-2.0-flash-exp",
|
||||
"cost_per_input_token": 0.00000075, # $0.75 per 1M tokens
|
||||
"cost_per_output_token": 0.000003, # $3 per 1M tokens
|
||||
"description": "Gemini 2.0 Flash Experimental"
|
||||
},
|
||||
{
|
||||
"model_name": "gemini-1.5-flash",
|
||||
"cost_per_input_token": 0.00000075, # $0.75 per 1M tokens
|
||||
"cost_per_output_token": 0.000003, # $3 per 1M tokens
|
||||
"description": "Gemini 1.5 Flash"
|
||||
},
|
||||
{
|
||||
"model_name": "gemini-1.5-pro",
|
||||
"cost_per_input_token": 0.00000125, # $1.25 per 1M tokens
|
||||
"cost_per_output_token": 0.000005, # $5 per 1M tokens
|
||||
"description": "Gemini 1.5 Pro"
|
||||
}
|
||||
]
|
||||
|
||||
# Tavily pricing
|
||||
tavily_pricing = [
|
||||
{
|
||||
"model_name": "search",
|
||||
"cost_per_search": 0.001, # $0.001 per search
|
||||
"description": "Tavily Search API"
|
||||
}
|
||||
]
|
||||
|
||||
# Serper pricing
|
||||
serper_pricing = [
|
||||
{
|
||||
"model_name": "search",
|
||||
"cost_per_search": 0.001, # $0.001 per search
|
||||
"description": "Serper Google Search API"
|
||||
}
|
||||
]
|
||||
|
||||
# Stability AI pricing
|
||||
stability_pricing = [
|
||||
{
|
||||
"model_name": "stable-diffusion-xl",
|
||||
"cost_per_image": 0.01, # $0.01 per image
|
||||
"description": "Stable Diffusion XL"
|
||||
}
|
||||
]
|
||||
|
||||
# Create pricing records
|
||||
pricing_configs = [
|
||||
(APIProvider.GEMINI, gemini_pricing),
|
||||
(APIProvider.TAVILY, tavily_pricing),
|
||||
(APIProvider.SERPER, serper_pricing),
|
||||
(APIProvider.STABILITY, stability_pricing)
|
||||
]
|
||||
|
||||
for provider, pricing_list in pricing_configs:
|
||||
for pricing_data in pricing_list:
|
||||
# Check if pricing already exists
|
||||
existing_pricing = db.query(APIProviderPricing).filter(
|
||||
APIProviderPricing.provider == provider,
|
||||
APIProviderPricing.model_name == pricing_data["model_name"]
|
||||
).first()
|
||||
|
||||
if existing_pricing:
|
||||
logger.info(f"✅ Pricing for {provider.value}/{pricing_data['model_name']} already exists")
|
||||
else:
|
||||
logger.info(f"🆕 Creating pricing for {provider.value}/{pricing_data['model_name']}")
|
||||
pricing = APIProviderPricing(
|
||||
provider=provider,
|
||||
**pricing_data
|
||||
)
|
||||
db.add(pricing)
|
||||
|
||||
db.commit()
|
||||
logger.info("✅ API provider pricing created successfully!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error creating API pricing: {e}")
|
||||
db.rollback()
|
||||
|
||||
def assign_default_plan_to_users():
|
||||
"""Assign Free Alpha plan to all existing users."""
|
||||
if os.getenv('ENABLE_ALPHA', 'false').lower() not in {'1','true','yes','on'}:
|
||||
@@ -234,32 +146,28 @@ def assign_default_plan_to_users():
|
||||
|
||||
db = get_db_session()
|
||||
if not db:
|
||||
logger.error("❌ Could not get database session")
|
||||
logger.error("Could not get database session")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Get Free Alpha plan
|
||||
free_plan = db.query(SubscriptionPlan).filter(
|
||||
SubscriptionPlan.name == "Free Alpha"
|
||||
).first()
|
||||
|
||||
if not free_plan:
|
||||
logger.error("❌ Free Alpha plan not found")
|
||||
logger.error("Free Alpha plan not found")
|
||||
return False
|
||||
|
||||
# For now, we'll create a default user subscription
|
||||
# In a real system, you'd query actual users
|
||||
from models.subscription_models import UserSubscription, BillingCycle, UsageStatus
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
# Create default user subscription for testing
|
||||
default_user_id = "default_user"
|
||||
existing_subscription = db.query(UserSubscription).filter(
|
||||
UserSubscription.user_id == default_user_id
|
||||
).first()
|
||||
|
||||
if not existing_subscription:
|
||||
logger.info(f"🆕 Creating default subscription for {default_user_id}")
|
||||
logger.info(f"Creating default subscription for {default_user_id}")
|
||||
subscription = UserSubscription(
|
||||
user_id=default_user_id,
|
||||
plan_id=free_plan.id,
|
||||
@@ -272,33 +180,32 @@ def assign_default_plan_to_users():
|
||||
)
|
||||
db.add(subscription)
|
||||
db.commit()
|
||||
logger.info(f"✅ Default subscription created for {default_user_id}")
|
||||
logger.info(f"Default subscription created for {default_user_id}")
|
||||
else:
|
||||
logger.info(f"✅ Default subscription already exists for {default_user_id}")
|
||||
logger.info(f"Default subscription already exists for {default_user_id}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error assigning default plan: {e}")
|
||||
logger.error(f"Error assigning default plan: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("🚀 Initializing Alpha Subscription Tiers...")
|
||||
logger.info("Initializing Alpha Subscription Tiers...")
|
||||
|
||||
success = create_alpha_subscription_tiers()
|
||||
if success:
|
||||
logger.info("✅ Subscription tiers created successfully!")
|
||||
logger.info("Subscription tiers created successfully!")
|
||||
|
||||
# Assign default plan
|
||||
assign_success = assign_default_plan_to_users()
|
||||
if assign_success:
|
||||
logger.info("✅ Default plan assigned successfully!")
|
||||
logger.info("Default plan assigned successfully!")
|
||||
else:
|
||||
logger.error("❌ Failed to assign default plan")
|
||||
logger.error("Failed to assign default plan")
|
||||
else:
|
||||
logger.error("❌ Failed to create subscription tiers")
|
||||
logger.error("Failed to create subscription tiers")
|
||||
|
||||
logger.info("🎉 Alpha subscription system initialization complete!")
|
||||
logger.info("Alpha subscription system initialization complete!")
|
||||
@@ -314,11 +314,14 @@ class ExaResearchProvider(BaseProvider):
|
||||
|
||||
def track_exa_usage(self, user_id: str, cost: float):
|
||||
"""Track Exa API usage after successful call."""
|
||||
from services.database import get_db
|
||||
from services.database import get_session_for_user
|
||||
from services.subscription import PricingService
|
||||
from sqlalchemy import text
|
||||
|
||||
db = next(get_db())
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
logger.warning(f"[track_exa_usage] Could not get DB session for user {user_id}")
|
||||
return
|
||||
try:
|
||||
pricing_service = PricingService(db)
|
||||
current_period = pricing_service.get_current_billing_period(user_id)
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
from typing import Optional, List
|
||||
|
||||
@@ -386,12 +387,15 @@ def get_db(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
user_id = current_user.get('id') or current_user.get('clerk_user_id')
|
||||
if not user_id:
|
||||
# Fallback or error? For now log error
|
||||
logger.error("No user ID found in context for DB connection")
|
||||
# Could raise exception, but let's try to be safe
|
||||
raise Exception("User ID required for database access")
|
||||
raise HTTPException(status_code=401, detail="User ID required for database access")
|
||||
|
||||
try:
|
||||
engine = get_engine_for_user(user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"[DB] Failed to create engine for user {user_id}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Database temporarily unavailable")
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
||||
@@ -67,10 +67,11 @@ import sys
|
||||
from pathlib import Path
|
||||
import google.genai as genai
|
||||
from google.genai import types
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from loguru import logger
|
||||
from utils.logger_utils import get_service_logger
|
||||
from services.api_key_manager import APIKeyManager
|
||||
|
||||
# Use service-specific logger to avoid conflicts
|
||||
logger = get_service_logger("gemini_audio_text")
|
||||
|
||||
@@ -8,11 +8,11 @@ import numpy as np
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.patches as mpatches
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from moviepy import (
|
||||
VideoFileClip, ImageClip, CompositeVideoClip,
|
||||
concatenate_videoclips,
|
||||
@@ -51,7 +51,7 @@ def crossfade_concat(scenes: list, fade_dur: float = 0.5):
|
||||
class Insight:
|
||||
key_insight: str
|
||||
supporting_stat: str
|
||||
visual_cue: str # bar_chart_comparison | line_trend | bullet_points | full_avatar
|
||||
visual_cue: str # bar_comparison|bar_horizontal|line_trend|pie|stacked_bar|bullet_points|full_avatar
|
||||
audio_tone: str
|
||||
chart_data: dict = field(default_factory=dict)
|
||||
duration: float = 10.0
|
||||
@@ -173,39 +173,6 @@ def make_horizontal_bar(data: dict, out_path: str, title: str = "",
|
||||
return out_path
|
||||
|
||||
|
||||
def make_line_trend(data: dict, out_path: str, title: str = "",
|
||||
show_area: bool = True, show_markers: bool = True) -> str:
|
||||
"""Render a trend line chart."""
|
||||
x_vals = data.get("x", [])
|
||||
y_vals = data.get("y", [])
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||
ax.set_facecolor("none")
|
||||
|
||||
line_style = data.get("line_style", "-")
|
||||
line_width = data.get("line_width", 2.5)
|
||||
|
||||
ax.plot(x_vals, y_vals, color=CHART_STYLE["accent"],
|
||||
linewidth=line_width, linestyle=line_style,
|
||||
marker="o" if show_markers else None, markersize=7, zorder=3)
|
||||
|
||||
if show_area:
|
||||
ax.fill_between(x_vals, y_vals, alpha=0.12, color=CHART_STYLE["accent"])
|
||||
|
||||
ax.spines[:].set_visible(False)
|
||||
ax.tick_params(colors=CHART_STYLE["text"])
|
||||
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
|
||||
|
||||
if title:
|
||||
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||
fontweight="bold", pad=12)
|
||||
|
||||
fig.tight_layout(pad=0.5)
|
||||
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
return out_path
|
||||
|
||||
|
||||
def make_pie_chart(data: dict, out_path: str, title: str = "",
|
||||
show_labels: bool = True, show_percent: bool = True,
|
||||
donut: bool = False) -> str:
|
||||
@@ -304,17 +271,33 @@ def make_stacked_bar(data: dict, out_path: str, title: str = "",
|
||||
|
||||
def make_line_trend(data: dict, out_path: str, title: str = "") -> str:
|
||||
"""Render a trend line chart. Returns output path."""
|
||||
x_vals = data.get("x", [])
|
||||
y_vals = data.get("y", [])
|
||||
x_labels = data.get("labels", data.get("x", []))
|
||||
y_vals = data.get("values", data.get("y", []))
|
||||
|
||||
if not x_labels or not y_vals:
|
||||
return ""
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||
ax.set_facecolor("none")
|
||||
|
||||
try:
|
||||
x_vals = [float(v) for v in x_labels]
|
||||
except (ValueError, TypeError):
|
||||
x_vals = list(range(len(x_labels)))
|
||||
|
||||
ax.plot(x_vals, y_vals, color=CHART_STYLE["accent"],
|
||||
linewidth=2.5, marker="o", markersize=7, zorder=3)
|
||||
ax.fill_between(x_vals, y_vals, alpha=0.12, color=CHART_STYLE["accent"])
|
||||
ax.spines[:].set_visible(False)
|
||||
ax.tick_params(colors=CHART_STYLE["text"])
|
||||
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
|
||||
|
||||
try:
|
||||
x_labels_f = [float(v) for v in x_labels]
|
||||
except (ValueError, TypeError):
|
||||
ax.set_xticks(x_vals)
|
||||
ax.set_xticklabels(x_labels, color=CHART_STYLE["text"], fontsize=10)
|
||||
|
||||
if title:
|
||||
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||
fontweight="bold", pad=12)
|
||||
@@ -514,13 +497,30 @@ def dispatch_scene(insight: Insight, assets: SceneAssets,
|
||||
if cue == "full_avatar":
|
||||
return build_full_avatar_scene(assets, insight)
|
||||
|
||||
elif cue in ("bar_chart_comparison", "line_trend"):
|
||||
elif cue in ("bar_comparison", "bar_chart_comparison", "bar_horizontal", "line_trend", "pie", "stacked_bar"):
|
||||
chart_path = "/tmp/chart.png"
|
||||
if cue == "bar_chart_comparison":
|
||||
make_bar_chart(insight.chart_data, chart_path,
|
||||
chart_data = insight.chart_data or {}
|
||||
if cue in ("bar_comparison", "bar_chart_comparison"):
|
||||
# Normalize {labels, values} -> {labels, before, after} for make_bar_chart
|
||||
if not chart_data.get("before") and not chart_data.get("after"):
|
||||
values = chart_data.get("values", [])
|
||||
labels = chart_data.get("labels", [])
|
||||
if values and labels:
|
||||
n = min(len(labels), len(values))
|
||||
chart_data = {**chart_data, "labels": labels[:n], "before": [0] * n, "after": values[:n]}
|
||||
make_bar_chart(chart_data, chart_path,
|
||||
title=insight.key_insight)
|
||||
else:
|
||||
make_line_trend(insight.chart_data, chart_path,
|
||||
elif cue == "bar_horizontal":
|
||||
make_horizontal_bar(chart_data, chart_path,
|
||||
title=insight.key_insight)
|
||||
elif cue == "line_trend":
|
||||
make_line_trend(chart_data, chart_path,
|
||||
title=insight.key_insight)
|
||||
elif cue == "pie":
|
||||
make_pie_chart(chart_data, chart_path,
|
||||
title=insight.key_insight)
|
||||
elif cue == "stacked_bar":
|
||||
make_stacked_bar(chart_data, chart_path,
|
||||
title=insight.key_insight)
|
||||
assets.chart_img = chart_path
|
||||
return build_data_scene(assets, insight)
|
||||
|
||||
@@ -12,11 +12,15 @@ 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
|
||||
from services.podcast.broll_composer import (
|
||||
Insight,
|
||||
SceneAssets,
|
||||
dispatch_scene,
|
||||
compose_video,
|
||||
make_bar_chart,
|
||||
make_horizontal_bar,
|
||||
make_line_trend,
|
||||
@@ -30,31 +34,46 @@ 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}")
|
||||
logger.warning(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
|
||||
|
||||
def get_chart_preview_filename(self, chart_id: str) -> str:
|
||||
"""Build deterministic chart preview filename from chart ID."""
|
||||
return f"chart_preview_{chart_id}.png"
|
||||
|
||||
def get_chart_preview_path(self, chart_id: str) -> Path:
|
||||
"""Get deterministic chart preview path from chart ID."""
|
||||
return self.get_output_path(self.get_chart_preview_filename(chart_id))
|
||||
|
||||
def generate_chart_preview(
|
||||
self,
|
||||
chart_data: Dict[str, Any],
|
||||
chart_type: str = "bar_comparison",
|
||||
title: str = "",
|
||||
subtitle: str = "",
|
||||
chart_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a chart PNG preview (static, for Write phase).
|
||||
@@ -68,34 +87,131 @@ class BrollService:
|
||||
Returns:
|
||||
Path to generated PNG file
|
||||
"""
|
||||
chart_id = uuid.uuid4().hex[:8]
|
||||
out_path = str(self.get_output_path(f"chart_preview_{chart_id}.png"))
|
||||
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:
|
||||
# Normalize to same length, truncating or padding as needed
|
||||
n = min(len(labels), len(values))
|
||||
labels = labels[:n]
|
||||
before = [0] * n
|
||||
after = values[:n]
|
||||
# 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)
|
||||
logger.warning(f"[BrollService] bar_comparison rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||
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)
|
||||
logger.warning(f"[BrollService] bar_horizontal rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||
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)
|
||||
logger.warning(f"[BrollService] line_trend rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||
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 == "pie":
|
||||
make_pie_chart(chart_data, out_path, title)
|
||||
logger.warning(f"[BrollService] pie rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||
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":
|
||||
logger.warning(f"[BrollService] stacked_bar rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||
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)
|
||||
logger.warning(f"[BrollService] bullet_points rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||
else:
|
||||
logger.warning("[BrollService] No bullet points provided")
|
||||
return ""
|
||||
else:
|
||||
logger.warning(f"[BrollService] Unknown chart type: {chart_type}")
|
||||
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}")
|
||||
logger.warning(f"[BrollService] Chart preview generated: {out_path}, exists={os.path.exists(out_path) if out_path else 'N/A'}")
|
||||
|
||||
# Add source attribution overlay if present
|
||||
source = chart_data.get("source", "").strip()
|
||||
if source and out_path and os.path.exists(out_path):
|
||||
try:
|
||||
from PIL import Image as PILImage, ImageDraw, ImageFont
|
||||
img = PILImage.open(out_path).convert("RGBA")
|
||||
draw = ImageDraw.Draw(img)
|
||||
source_text = f"Source: {source[:80]}"
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
|
||||
except (OSError, IOError):
|
||||
try:
|
||||
font = ImageFont.truetype("arial.ttf", 11)
|
||||
except (OSError, IOError):
|
||||
font = ImageFont.load_default()
|
||||
text_bbox = draw.textbbox((0, 0), source_text, font=font)
|
||||
text_w = text_bbox[2] - text_bbox[0]
|
||||
text_h = text_bbox[3] - text_bbox[1]
|
||||
x = img.width - text_w - 12
|
||||
y = img.height - text_h - 8
|
||||
draw.rectangle([x - 4, y - 2, x + text_w + 4, y + text_h + 2], fill=(0, 0, 0, 140))
|
||||
draw.text((x, y), source_text, fill=(200, 200, 200, 220), font=font)
|
||||
img.save(out_path)
|
||||
except Exception as src_err:
|
||||
logger.warning(f"[BrollService] Source overlay failed (non-fatal): {src_err}")
|
||||
|
||||
return out_path
|
||||
|
||||
except Exception as e:
|
||||
@@ -108,7 +224,7 @@ class BrollService:
|
||||
key_insight: str,
|
||||
supporting_stat: str,
|
||||
chart_data: Optional[Dict[str, Any]],
|
||||
visual_cue: str, # bar_chart_comparison, bullet_points, full_avatar
|
||||
visual_cue: str, # bar_comparison, bar_horizontal, line_trend, pie, stacked_bar, bullet_points, full_avatar
|
||||
duration: float,
|
||||
background_img_path: str,
|
||||
avatar_video_path: Optional[str] = None,
|
||||
@@ -217,7 +333,7 @@ class BrollService:
|
||||
logger.error(f"[BrollService] Failed to compose final video: {e}")
|
||||
raise
|
||||
|
||||
def cleanup(self, file_paths: List[str] = None):
|
||||
def cleanup(self, file_paths: Optional[List[str]] = None):
|
||||
"""
|
||||
Clean up temporary B-roll files.
|
||||
|
||||
@@ -241,13 +357,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]
|
||||
|
||||
@@ -17,20 +17,26 @@ from loguru import logger
|
||||
class PodcastVideoCombinationService:
|
||||
"""Service for combining podcast scene videos into final episodes."""
|
||||
|
||||
def __init__(self, output_dir: Optional[str] = None):
|
||||
def __init__(self, output_dir: Optional[str] = None, user_id: Optional[str] = None):
|
||||
"""
|
||||
Initialize the podcast video combination service.
|
||||
|
||||
Parameters:
|
||||
output_dir (str, optional): Directory to save combined videos.
|
||||
Defaults to 'backend/podcast_videos/Final_Videos' if not provided.
|
||||
user_id (str, optional): User ID for workspace-scoped output.
|
||||
|
||||
Either output_dir or user_id must be provided for workspace isolation.
|
||||
"""
|
||||
if output_dir:
|
||||
self.output_dir = Path(output_dir)
|
||||
elif user_id:
|
||||
from api.podcast.constants import get_podcast_media_dir
|
||||
self.output_dir = get_podcast_media_dir("video", user_id, ensure_exists=True) / "Final_Videos"
|
||||
else:
|
||||
# Default to root/data/media/podcast_videos/Final_Videos directory
|
||||
base_dir = Path(__file__).resolve().parents[3]
|
||||
self.output_dir = base_dir / "data" / "media" / "podcast_videos" / "Final_Videos"
|
||||
from utils.storage_paths import get_user_workspace, sanitize_user_id
|
||||
logger.warning("[PodcastVideoCombination] No output_dir or user_id provided — using default workspace. This should not happen in production.")
|
||||
default_user = sanitize_user_id("alwrity")
|
||||
self.output_dir = get_user_workspace(default_user) / "media" / "podcast_videos" / "Final_Videos"
|
||||
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"[PodcastVideoCombination] Initialized with output directory: {self.output_dir}")
|
||||
|
||||
281
backend/services/podcast_context_builder.py
Normal file
281
backend/services/podcast_context_builder.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Podcast Context Builder Service
|
||||
|
||||
Builds unified context for AI prompts from multiple sources:
|
||||
- Podcast Bible (user personalization)
|
||||
- Website Extraction (from Exa)
|
||||
- Topic Context (category research: News/Finance)
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class PodcastContextBuilder:
|
||||
"""Builds unified context for AI prompt enhancements."""
|
||||
|
||||
def build_enhance_context(
|
||||
self,
|
||||
idea: str,
|
||||
bible_context: str = "",
|
||||
website_data: Optional[Dict[str, Any]] = None,
|
||||
topic_context: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build context for topic enhancement prompt.
|
||||
|
||||
Args:
|
||||
idea: Raw podcast idea/keywords
|
||||
bible_context: Serialized Podcast Bible string
|
||||
website_data: Website extraction data (title, summary, highlights, url, subpages)
|
||||
topic_context: Category research data (category, topics, selected_topic)
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- prompt: The formatted prompt
|
||||
- contexts_used: List of context types being used
|
||||
- context_description: Human-readable description for logging
|
||||
"""
|
||||
contexts_used = []
|
||||
context_parts = []
|
||||
|
||||
# Track what contexts are available
|
||||
if bible_context:
|
||||
contexts_used.append("Podcast Bible")
|
||||
|
||||
if website_data:
|
||||
contexts_used.append("Website Analysis")
|
||||
|
||||
if topic_context:
|
||||
category = topic_context.get("category", "unknown")
|
||||
contexts_used.append(f"Category Research ({category})")
|
||||
|
||||
# Build Bible section
|
||||
if bible_context:
|
||||
context_parts.append(f"USER PERSONALIZATION CONTEXT (Podcast Bible):\n{bible_context}")
|
||||
|
||||
# Build Website section
|
||||
if website_data:
|
||||
website_section = self._format_website_section(website_data)
|
||||
context_parts.append(website_section)
|
||||
|
||||
# Build Topic/Category section
|
||||
if topic_context:
|
||||
topic_section = self._format_topic_section(topic_context)
|
||||
context_parts.append(topic_section)
|
||||
|
||||
# Select appropriate prompt template based on available context
|
||||
prompt = self._select_prompt(idea, context_parts, website_data, topic_context)
|
||||
|
||||
return {
|
||||
"prompt": prompt,
|
||||
"contexts_used": contexts_used,
|
||||
"context_description": ", ".join(contexts_used) if contexts_used else "basic idea only",
|
||||
}
|
||||
|
||||
def _format_website_section(self, website_data: Dict[str, Any]) -> str:
|
||||
"""Format website data for prompt inclusion."""
|
||||
parts = []
|
||||
|
||||
if website_data.get("url"):
|
||||
parts.append(f"Source URL: {website_data['url']}")
|
||||
|
||||
if website_data.get("title"):
|
||||
parts.append(f"Company/Organization: {website_data['title']}")
|
||||
|
||||
if website_data.get("summary"):
|
||||
parts.append(f"About: {website_data['summary']}")
|
||||
|
||||
if website_data.get("highlights"):
|
||||
highlights = website_data.get("highlights", [])
|
||||
if highlights:
|
||||
parts.append(f"Key Highlights: {', '.join(highlights[:3])}")
|
||||
|
||||
if website_data.get("subpages"):
|
||||
subpages = website_data.get("subpages", [])
|
||||
if subpages:
|
||||
subpage_titles = [sp.get("title", sp.get("url", "")) for sp in subpages[:3]]
|
||||
parts.append(f"Subpages: {', '.join(subpage_titles)}")
|
||||
|
||||
return "WEBSITE CONTENT ANALYSIS:\n" + "\n".join(parts)
|
||||
|
||||
def _format_topic_section(self, topic_context: Dict[str, Any]) -> str:
|
||||
"""Format category research data for prompt inclusion."""
|
||||
parts = []
|
||||
|
||||
category = topic_context.get("category", "")
|
||||
if category:
|
||||
parts.append(f"Research Category: {category.upper()}")
|
||||
|
||||
# Include selected topic details
|
||||
selected = topic_context.get("selected_topic", {})
|
||||
if selected:
|
||||
if selected.get("title"):
|
||||
parts.append(f"Selected Topic: {selected['title']}")
|
||||
if selected.get("snippet"):
|
||||
parts.append(f"Context: {selected['snippet']}")
|
||||
if selected.get("url"):
|
||||
parts.append(f"Source: {selected['url']}")
|
||||
|
||||
# Include some alternative topics for reference
|
||||
topics = topic_context.get("topics", [])
|
||||
if topics:
|
||||
alt_titles = [t.get("title", "") for t in topics[:3] if t.get("title")]
|
||||
if alt_titles:
|
||||
parts.append(f"Related Topics: {', '.join(alt_titles)}")
|
||||
|
||||
return "CATEGORY RESEARCH CONTEXT:\n" + "\n".join(parts)
|
||||
|
||||
def _select_prompt(
|
||||
self,
|
||||
idea: str,
|
||||
context_parts: List[str],
|
||||
website_data: Optional[Dict[str, Any]],
|
||||
topic_context: Optional[Dict[str, Any]],
|
||||
) -> str:
|
||||
"""Select and format the appropriate prompt based on available context."""
|
||||
|
||||
context_str = "\n\n".join(context_parts)
|
||||
|
||||
# Full context prompt (all sources available)
|
||||
if website_data and topic_context:
|
||||
return f"""You are a creative podcast producer. Generate 3 distinct, compelling podcast episode concepts from the raw idea, enriched with website content analysis AND category research.
|
||||
|
||||
{context_str}
|
||||
|
||||
RAW IDEA/KEYWORDS: "{idea}"
|
||||
|
||||
TASK:
|
||||
Generate 3 different enhanced versions that INCORPORATE both the website content AND category research context:
|
||||
1. Professional & Expert-led angle (leverage website authority + research insights)
|
||||
2. Storytelling & Human interest angle (brand narratives + research findings)
|
||||
3. Trendy & Contemporary angle (current trends + research relevance)
|
||||
|
||||
Each version should:
|
||||
- Be 2-3 sentences
|
||||
- Reference specific elements from both website AND research when relevant
|
||||
- Be audience-focused and align with host persona if provided
|
||||
- NOT just repeat summaries - create fresh podcast angles
|
||||
|
||||
Return JSON with:
|
||||
- enhanced_ideas: array of 3 strings (each a complete episode pitch)
|
||||
- rationales: array of 3 strings explaining each approach
|
||||
|
||||
Example format:
|
||||
{{
|
||||
"enhanced_ideas": ["Pitch 1...", "Pitch 2...", "Pitch 3..."],
|
||||
"rationales": ["Reason 1", "Reason 2", "Reason 3"]
|
||||
}}
|
||||
"""
|
||||
|
||||
# Website-only context
|
||||
elif website_data:
|
||||
return f"""You are a creative podcast producer. Generate 3 distinct, compelling podcast episode concepts from the raw idea, enriched with website content analysis.
|
||||
|
||||
{context_str}
|
||||
|
||||
RAW IDEA/KEYWORDS: "{idea}"
|
||||
|
||||
TASK:
|
||||
Generate 3 different enhanced versions that INCORPORATE the website content:
|
||||
1. Professional & Expert-led angle (focus on authority, insights from website)
|
||||
2. Storytelling & Human interest angle (brand narratives, personal connections)
|
||||
3. Trendy & Contemporary angle (modern perspectives, current relevance)
|
||||
|
||||
Each version should:
|
||||
- Be 2-3 sentences
|
||||
- Reference specific elements from the website when relevant
|
||||
- Be audience-focused and align with host persona if provided
|
||||
|
||||
Return JSON with:
|
||||
- enhanced_ideas: array of 3 strings
|
||||
- rationales: array of 3 strings
|
||||
|
||||
Example format:
|
||||
{{
|
||||
"enhanced_ideas": ["Pitch 1...", "Pitch 2...", "Pitch 3..."],
|
||||
"rationales": ["Reason 1", "Reason 2", "Reason 3"]
|
||||
}}
|
||||
"""
|
||||
|
||||
# Category research only context
|
||||
elif topic_context:
|
||||
category = topic_context.get("category", "research").upper()
|
||||
return f"""You are a creative podcast producer. Generate 3 distinct, compelling podcast episode concepts from the raw idea, enriched with {category} category research.
|
||||
|
||||
{context_str}
|
||||
|
||||
RAW IDEA/KEYWORDS: "{idea}"
|
||||
|
||||
TASK:
|
||||
Generate 3 different enhanced versions that INCORPORATE the {category} research:
|
||||
1. Professional & Expert-led angle (leverage research insights and data)
|
||||
2. Storytelling & Human interest angle (real-world applications, human impact)
|
||||
3. Trendy & Contemporary angle (cutting-edge trends, future outlook)
|
||||
|
||||
Each version should:
|
||||
- Be 2-3 sentences
|
||||
- Reference specific elements from the research when relevant
|
||||
- Connect the research to the raw idea meaningfully
|
||||
|
||||
Return JSON with:
|
||||
- enhanced_ideas: array of 3 strings
|
||||
- rationales: array of 3 strings
|
||||
|
||||
Example format:
|
||||
{{
|
||||
"enhanced_ideas": ["Pitch 1...", "Pitch 2...", "Pitch 3..."],
|
||||
"rationales": ["Reason 1", "Reason 2", "Reason 3"]
|
||||
}}
|
||||
"""
|
||||
|
||||
# Standard context (no additional context)
|
||||
else:
|
||||
return f"""You are a creative podcast producer. Generate 3 distinct, compelling podcast episode concepts from the raw idea.
|
||||
|
||||
{context_str}
|
||||
|
||||
RAW IDEA/KEYWORDS: "{idea}"
|
||||
|
||||
TASK:
|
||||
Generate 3 different enhanced versions with unique angles:
|
||||
1. Professional & Expert-led angle (focus on authority, insights)
|
||||
2. Storytelling & Human interest angle (focus on narratives, emotions)
|
||||
3. Trendy & Contemporary angle (focus on trends, modern relevance)
|
||||
|
||||
Each version should be 2-3 sentences, audience-focused.
|
||||
|
||||
Return JSON with:
|
||||
- enhanced_ideas: array of 3 strings
|
||||
- rationales: array of 3 strings
|
||||
|
||||
Example format:
|
||||
{{
|
||||
"enhanced_ideas": ["Pitch 1...", "Pitch 2...", "Pitch 3..."],
|
||||
"rationales": ["Reason 1", "Reason 2", "Reason 3"]
|
||||
}}
|
||||
"""
|
||||
|
||||
def format_context_for_logging(
|
||||
self,
|
||||
website_data: Optional[Dict] = None,
|
||||
topic_context: Optional[Dict] = None,
|
||||
) -> str:
|
||||
"""Format context description for logging."""
|
||||
contexts = []
|
||||
|
||||
if website_data:
|
||||
title = website_data.get("title", "Unknown")
|
||||
contexts.append(f"Website: {title[:30]}...")
|
||||
|
||||
if topic_context:
|
||||
category = topic_context.get("category", "unknown")
|
||||
selected = topic_context.get("selected_topic", {})
|
||||
topic_title = selected.get("title", "Not selected")
|
||||
contexts.append(f"Category: {category} ({topic_title[:20]}...)")
|
||||
|
||||
return " | ".join(contexts) if contexts else "No extended context"
|
||||
|
||||
|
||||
# Singleton instance for reuse
|
||||
context_builder = PodcastContextBuilder()
|
||||
@@ -85,18 +85,26 @@ class PodcastService:
|
||||
**updates
|
||||
) -> Optional[PodcastProject]:
|
||||
"""Update project fields."""
|
||||
from loguru import logger
|
||||
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:
|
||||
logger.warning(f"[PodcastService] update_project: project not found")
|
||||
return None
|
||||
|
||||
# Update fields
|
||||
for key, value in updates.items():
|
||||
if hasattr(project, key):
|
||||
setattr(project, key, value)
|
||||
else:
|
||||
logger.warning(f"[PodcastService] update_project: field '{key}' not in model")
|
||||
|
||||
project.updated_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
self.db.refresh(project)
|
||||
logger.warning(f"[PodcastService] update_project: success")
|
||||
return project
|
||||
|
||||
def list_projects(
|
||||
|
||||
@@ -4,23 +4,131 @@ Google Trends Service
|
||||
Provides Google Trends data integration for the Research Engine.
|
||||
Handles rate limiting, caching, error handling, and data serialization.
|
||||
|
||||
Key design decisions:
|
||||
- Monkey-patches urllib3 Retry to fix method_whitelist→allowed_methods (urllib3 2.x)
|
||||
- Monkey-patches pytrends related_topics/related_queries to catch IndexError bug
|
||||
- Uses TrendReq built-in retries (3 retries, 1s backoff) for automatic 429 handling
|
||||
- Random user-agent rotation per instance to reduce fingerprinting
|
||||
- 1-second delays between sequential requests to respect rate limits
|
||||
- 24-hour in-memory cache to avoid redundant API calls
|
||||
|
||||
Author: ALwrity Team
|
||||
Version: 1.0
|
||||
Version: 2.0
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from loguru import logger
|
||||
import pandas as pd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Monkey-patches: fix compatibility issues before importing/using pytrends
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Patch 1: urllib3 2.x renamed Retry's `method_whitelist` to `allowed_methods`.
|
||||
# pytrends 4.9.2 still uses `method_whitelist`, which crashes with urllib3 2.x.
|
||||
# We patch Retry.__init__ to accept `method_whitelist` and remap it.
|
||||
try:
|
||||
from pytrends.request import TrendReq
|
||||
from urllib3.util.retry import Retry as _OrigRetry
|
||||
|
||||
_orig_retry_init = _OrigRetry.__init__
|
||||
|
||||
def _patched_retry_init(self, *args, **kwargs):
|
||||
if 'method_whitelist' in kwargs and 'allowed_methods' not in kwargs:
|
||||
kwargs['allowed_methods'] = kwargs.pop('method_whitelist')
|
||||
_orig_retry_init(self, *args, **kwargs)
|
||||
|
||||
_OrigRetry.__init__ = _patched_retry_init
|
||||
logger.debug("[Trends] Patched urllib3 Retry.__init__ for method_whitelist→allowed_methods")
|
||||
except Exception as _patch_err:
|
||||
logger.warning(f"[Trends] Could not patch urllib3 Retry: {_patch_err}")
|
||||
|
||||
# Now safe to import pytrends
|
||||
try:
|
||||
from pytrends.request import TrendReq as _TrendReq
|
||||
PYTrends_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYTrends_AVAILABLE = False
|
||||
logger.warning("pytrends not installed. Google Trends features will be unavailable.")
|
||||
|
||||
# Patch 2: pytrends related_topics() and related_queries() use keyword[0]
|
||||
# which raises IndexError on empty lists, but only catch KeyError.
|
||||
# We fix this by catching (KeyError, IndexError) for the keyword extraction.
|
||||
if PYTrends_AVAILABLE:
|
||||
import json as _json
|
||||
import pandas as _pd
|
||||
|
||||
def _fixed_related_topics(self):
|
||||
result_dict = {}
|
||||
related_payload = {}
|
||||
for request_json in self.related_topics_widget_list:
|
||||
try:
|
||||
kw = request_json['request']['restriction'][
|
||||
'complexKeywordsRestriction']['keyword'][0]['value']
|
||||
except (KeyError, IndexError):
|
||||
kw = ''
|
||||
related_payload['req'] = _json.dumps(request_json['request'])
|
||||
related_payload['token'] = request_json['token']
|
||||
related_payload['tz'] = self.tz
|
||||
req_json = self._get_data(
|
||||
url=_TrendReq.RELATED_QUERIES_URL,
|
||||
method=_TrendReq.GET_METHOD,
|
||||
trim_chars=5,
|
||||
params=related_payload,
|
||||
)
|
||||
try:
|
||||
top_list = req_json['default']['rankedList'][0]['rankedKeyword']
|
||||
df_top = _pd.json_normalize(top_list, sep='_')
|
||||
except (KeyError, IndexError):
|
||||
df_top = None
|
||||
try:
|
||||
rising_list = req_json['default']['rankedList'][1]['rankedKeyword']
|
||||
df_rising = _pd.json_normalize(rising_list, sep='_')
|
||||
except (KeyError, IndexError):
|
||||
df_rising = None
|
||||
result_dict[kw] = {'rising': df_rising, 'top': df_top}
|
||||
return result_dict
|
||||
|
||||
def _fixed_related_queries(self):
|
||||
result_dict = {}
|
||||
related_payload = {}
|
||||
for request_json in self.related_queries_widget_list:
|
||||
try:
|
||||
kw = request_json['request']['restriction'][
|
||||
'complexKeywordsRestriction']['keyword'][0]['value']
|
||||
except (KeyError, IndexError):
|
||||
kw = ''
|
||||
related_payload['req'] = _json.dumps(request_json['request'])
|
||||
related_payload['token'] = request_json['token']
|
||||
related_payload['tz'] = self.tz
|
||||
req_json = self._get_data(
|
||||
url=_TrendReq.RELATED_QUERIES_URL,
|
||||
method=_TrendReq.GET_METHOD,
|
||||
trim_chars=5,
|
||||
params=related_payload,
|
||||
)
|
||||
try:
|
||||
top_df = _pd.DataFrame(
|
||||
req_json['default']['rankedList'][0]['rankedKeyword'])
|
||||
top_df = top_df[['query', 'value']]
|
||||
except (KeyError, IndexError):
|
||||
top_df = None
|
||||
try:
|
||||
rising_df = _pd.DataFrame(
|
||||
req_json['default']['rankedList'][1]['rankedKeyword'])
|
||||
rising_df = rising_df[['query', 'value']]
|
||||
except (KeyError, IndexError):
|
||||
rising_df = None
|
||||
result_dict[kw] = {'top': top_df, 'rising': rising_df}
|
||||
return result_dict
|
||||
|
||||
_TrendReq.related_topics = _fixed_related_topics
|
||||
_TrendReq.related_queries = _fixed_related_queries
|
||||
logger.debug("[Trends] Patched TrendReq.related_topics/related_queries for IndexError")
|
||||
|
||||
from .rate_limiter import RateLimiter
|
||||
|
||||
|
||||
@@ -28,56 +136,54 @@ class GoogleTrendsService:
|
||||
"""
|
||||
Service for fetching and analyzing Google Trends data.
|
||||
|
||||
Features:
|
||||
- Interest over time
|
||||
- Interest by region
|
||||
- Related topics
|
||||
- Related queries
|
||||
- Rate limiting (1 req/sec)
|
||||
- Caching (24-hour TTL)
|
||||
- Async support
|
||||
- Error handling with retry logic
|
||||
Uses TrendReq with no retries (fail-fast) to avoid hitting CAPTCHA on blocks.
|
||||
429 retry handling (1s, 2s, 4s backoff). Random user-agent is set
|
||||
per instance to reduce fingerprinting.
|
||||
"""
|
||||
|
||||
USER_AGENTS = [
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0",
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Google Trends service."""
|
||||
if not PYTrends_AVAILABLE:
|
||||
raise RuntimeError("pytrends library is required. Install with: pip install pytrends")
|
||||
|
||||
self.rate_limiter = RateLimiter(max_calls=1, period=1.0) # 1 request per second
|
||||
self.cache: Dict[str, Dict[str, Any]] = {} # Simple in-memory cache
|
||||
self.cache_ttl = timedelta(hours=24) # 24-hour cache
|
||||
self.rate_limiter = RateLimiter(max_calls=1, period=1.0)
|
||||
self.cache: Dict[str, Any] = {}
|
||||
self.cache_ttl = timedelta(hours=24)
|
||||
|
||||
logger.info("GoogleTrendsService initialized")
|
||||
logger.info("GoogleTrendsService initialized (pytrends 4.9.2, fail-fast, 2s delays)")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Public API
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def analyze_trends(
|
||||
self,
|
||||
keywords: List[str],
|
||||
timeframe: str = "today 12-m",
|
||||
geo: str = "US",
|
||||
gprop: str = "",
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Comprehensive trends analysis.
|
||||
|
||||
Fetches all trends data in a single optimized call:
|
||||
- Interest over time
|
||||
- Interest by region
|
||||
- Related topics (top & rising)
|
||||
- Related queries (top & rising)
|
||||
|
||||
Args:
|
||||
keywords: List of keywords to analyze (1-5 keywords recommended)
|
||||
timeframe: Timeframe string (e.g., "today 12-m", "today 1-y", "all")
|
||||
keywords: List of keywords to analyze (1-5)
|
||||
timeframe: Timeframe (e.g., "today 12-m", "today 3-m", "today 5-y")
|
||||
geo: Country code (e.g., "US", "GB", "IN")
|
||||
user_id: User ID for subscription checks (optional for now)
|
||||
gprop: Google property filter - '' for web, 'youtube' for YouTube, 'news', 'images', 'froogle'
|
||||
user_id: Optional user ID for tracking
|
||||
|
||||
Returns:
|
||||
Dict containing all trends data in serializable format
|
||||
|
||||
Raises:
|
||||
ValueError: If keywords list is empty or too long
|
||||
RuntimeError: If pytrends is not available or API fails
|
||||
Fetches: interest over time, interest by region, related topics,
|
||||
and related queries using a single TrendReq session.
|
||||
"""
|
||||
if not keywords:
|
||||
raise ValueError("Keywords list cannot be empty")
|
||||
@@ -86,65 +192,85 @@ class GoogleTrendsService:
|
||||
logger.warning(f"Too many keywords ({len(keywords)}), using first 5")
|
||||
keywords = keywords[:5]
|
||||
|
||||
# Check cache first
|
||||
cache_key = self._build_cache_key(keywords, timeframe, geo)
|
||||
cached_data = self._get_from_cache(cache_key)
|
||||
if cached_data:
|
||||
logger.info(f"Returning cached trends data for: {keywords}")
|
||||
return {**cached_data, "cached": True}
|
||||
|
||||
# Rate limit
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching Google Trends data for: {keywords} (timeframe: {timeframe}, geo: {geo})")
|
||||
total_start = time.monotonic()
|
||||
|
||||
# Initialize pytrends (sync operation, run in thread)
|
||||
interest_over_time: List[Dict[str, Any]] = []
|
||||
interest_by_region: List[Dict[str, Any]] = []
|
||||
related_topics: Dict[str, List[Dict[str, Any]]] = {"top": [], "rising": []}
|
||||
related_queries: Dict[str, List[Dict[str, Any]]] = {"top": [], "rising": []}
|
||||
|
||||
try:
|
||||
logger.info(f"[Trends] ===== START analyze_trends ===== keywords={keywords} timeframe={timeframe} geo={geo}")
|
||||
|
||||
# Initialize TrendReq with gprop (youtube for video/podcast relevance)
|
||||
init_start = time.monotonic()
|
||||
pytrends = await asyncio.to_thread(
|
||||
self._initialize_pytrends,
|
||||
self._create_pytrends,
|
||||
keywords,
|
||||
timeframe,
|
||||
geo
|
||||
geo,
|
||||
gprop,
|
||||
)
|
||||
init_ms = int((time.monotonic() - init_start) * 1000)
|
||||
logger.info(f"[Trends] TrendReq init + build_payload took {init_ms}ms")
|
||||
|
||||
# --- Interest Over Time ---
|
||||
iot_start = time.monotonic()
|
||||
interest_over_time = await asyncio.to_thread(
|
||||
lambda: self._fetch_interest_over_time(pytrends)
|
||||
)
|
||||
iot_ms = int((time.monotonic() - iot_start) * 1000)
|
||||
logger.info(f"[Trends] interest_over_time took {iot_ms}ms, returned {len(interest_over_time)} points")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# --- Interest By Region ---
|
||||
ibr_start = time.monotonic()
|
||||
interest_by_region = await asyncio.to_thread(
|
||||
lambda: self._fetch_interest_by_region(pytrends)
|
||||
)
|
||||
ibr_ms = int((time.monotonic() - ibr_start) * 1000)
|
||||
logger.info(f"[Trends] interest_by_region took {ibr_ms}ms, returned {len(interest_by_region)} regions")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# --- Related Topics ---
|
||||
rt_start = time.monotonic()
|
||||
related_topics = await asyncio.to_thread(
|
||||
lambda: self._fetch_related_topics(pytrends)
|
||||
)
|
||||
rt_ms = int((time.monotonic() - rt_start) * 1000)
|
||||
rt_top = len(related_topics.get("top", []))
|
||||
rt_rising = len(related_topics.get("rising", []))
|
||||
logger.info(f"[Trends] related_topics took {rt_ms}ms, top={rt_top} rising={rt_rising}")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# --- Related Queries ---
|
||||
rq_start = time.monotonic()
|
||||
related_queries = await asyncio.to_thread(
|
||||
lambda: self._fetch_related_queries(pytrends)
|
||||
)
|
||||
rq_ms = int((time.monotonic() - rq_start) * 1000)
|
||||
rq_top = len(related_queries.get("top", []))
|
||||
rq_rising = len(related_queries.get("rising", []))
|
||||
logger.info(f"[Trends] related_queries took {rq_ms}ms, top={rq_top} rising={rq_rising}")
|
||||
|
||||
total_ms = int((time.monotonic() - total_start) * 1000)
|
||||
logger.info(
|
||||
f"[Trends] ===== DONE analyze_trends ===== total={total_ms}ms "
|
||||
f"iot={len(interest_over_time)} ibr={len(interest_by_region)} "
|
||||
f"rt_top={rt_top} rq_top={rq_top}"
|
||||
)
|
||||
|
||||
# Fetch all data in parallel (pytrends methods are sync, so use to_thread)
|
||||
interest_over_time_task = asyncio.to_thread(
|
||||
lambda: self._safe_interest_over_time(pytrends)
|
||||
)
|
||||
interest_by_region_task = asyncio.to_thread(
|
||||
lambda: self._safe_interest_by_region(pytrends)
|
||||
)
|
||||
related_topics_task = asyncio.to_thread(
|
||||
lambda: self._safe_related_topics(pytrends, keywords)
|
||||
)
|
||||
related_queries_task = asyncio.to_thread(
|
||||
lambda: self._safe_related_queries(pytrends, keywords)
|
||||
)
|
||||
|
||||
# Wait for all tasks
|
||||
interest_over_time, interest_by_region, related_topics, related_queries = await asyncio.gather(
|
||||
interest_over_time_task,
|
||||
interest_by_region_task,
|
||||
related_topics_task,
|
||||
related_queries_task,
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Handle exceptions
|
||||
if isinstance(interest_over_time, Exception):
|
||||
logger.error(f"Interest over time failed: {interest_over_time}")
|
||||
interest_over_time = []
|
||||
if isinstance(interest_by_region, Exception):
|
||||
logger.error(f"Interest by region failed: {interest_by_region}")
|
||||
interest_by_region = []
|
||||
if isinstance(related_topics, Exception):
|
||||
logger.error(f"Related topics failed: {related_topics}")
|
||||
related_topics = {"top": [], "rising": []}
|
||||
if isinstance(related_queries, Exception):
|
||||
logger.error(f"Related queries failed: {related_queries}")
|
||||
related_queries = {"top": [], "rising": []}
|
||||
|
||||
# Build result
|
||||
result = {
|
||||
"interest_over_time": interest_over_time,
|
||||
"interest_by_region": interest_by_region,
|
||||
@@ -153,176 +279,257 @@ class GoogleTrendsService:
|
||||
"timeframe": timeframe,
|
||||
"geo": geo,
|
||||
"keywords": keywords,
|
||||
"source": "web" if gprop == "" else "podcast" if gprop == "youtube" else gprop,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"cached": False
|
||||
"cached": False,
|
||||
}
|
||||
|
||||
# Cache result
|
||||
self._save_to_cache(cache_key, result)
|
||||
|
||||
logger.info(f"Google Trends data fetched successfully: {len(interest_over_time)} time points, {len(interest_by_region)} regions")
|
||||
logger.info(
|
||||
f"Google Trends data fetched successfully: "
|
||||
f"{len(interest_over_time)} time points, {len(interest_by_region)} regions"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Google Trends analysis failed: {e}")
|
||||
# Return fallback response
|
||||
return self._create_fallback_response(keywords, timeframe, geo, str(e))
|
||||
return self._create_fallback_response(keywords, timeframe, geo, gprop, str(e))
|
||||
|
||||
def _initialize_pytrends(
|
||||
# -----------------------------------------------------------------------
|
||||
# TrendReq factory
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _create_pytrends(
|
||||
self,
|
||||
keywords: List[str],
|
||||
timeframe: str,
|
||||
geo: str
|
||||
) -> TrendReq:
|
||||
"""Initialize pytrends and build payload (sync operation)."""
|
||||
pytrends = TrendReq(hl='en-US', tz=360)
|
||||
pytrends.build_payload(kw_list=keywords, timeframe=timeframe, geo=geo)
|
||||
geo: str,
|
||||
gprop: str = "",
|
||||
) -> _TrendReq:
|
||||
"""Create TrendReq with optional gprop (e.g., 'youtube' for video trends)."""
|
||||
start = time.monotonic()
|
||||
ua = random.choice(self.USER_AGENTS)
|
||||
logger.info(f"[Trends] Creating TrendReq (fail-fast, gprop='{gprop}', UA={ua[:40]}...)")
|
||||
pytrends = _TrendReq(
|
||||
hl='en-US',
|
||||
tz=360,
|
||||
timeout=(10, 30),
|
||||
retries=0,
|
||||
backoff_factor=0,
|
||||
requests_args={'headers': {'User-Agent': ua}},
|
||||
)
|
||||
# gprop: '' = web, 'youtube' = YouTube, 'news', 'images', 'froogle'
|
||||
pytrends.build_payload(kw_list=keywords, timeframe=timeframe, geo=geo, gprop=gprop)
|
||||
elapsed = int((time.monotonic() - start) * 1000)
|
||||
logger.info(f"[Trends] TrendReq init + build_payload completed in {elapsed}ms (gprop={gprop})")
|
||||
return pytrends
|
||||
|
||||
def _safe_interest_over_time(self, pytrends: TrendReq) -> List[Dict[str, Any]]:
|
||||
"""Safely fetch interest over time data."""
|
||||
# -----------------------------------------------------------------------
|
||||
# Data fetchers — each catches all exceptions and returns defaults
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _fetch_interest_over_time(self, pytrends: _TrendReq, keywords: List[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Fetch interest over time data."""
|
||||
start = time.monotonic()
|
||||
try:
|
||||
df = pytrends.interest_over_time()
|
||||
if df.empty:
|
||||
elapsed = int((time.monotonic() - start) * 1000)
|
||||
if df is None or (hasattr(df, 'empty') and df.empty):
|
||||
logger.info(f"[Trends] interest_over_time returned empty in {elapsed}ms")
|
||||
return []
|
||||
return self._format_dataframe(df.reset_index())
|
||||
# Use pytrends.kw_list if keywords not provided
|
||||
kw = keywords or pytrends.kw_list
|
||||
result = self._format_dataframe(df.reset_index(), kw)
|
||||
logger.info(f"[Trends] interest_over_time returned {len(result)} points in {elapsed}ms")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching interest over time: {e}")
|
||||
elapsed = int((time.monotonic() - start) * 1000)
|
||||
logger.error(f"[Trends] interest_over_time failed in {elapsed}ms: {e}")
|
||||
return []
|
||||
|
||||
def _safe_interest_by_region(self, pytrends: TrendReq) -> List[Dict[str, Any]]:
|
||||
"""Safely fetch interest by region data."""
|
||||
def _fetch_interest_by_region(self, pytrends: _TrendReq, keywords: List[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Fetch interest by region data."""
|
||||
start = time.monotonic()
|
||||
try:
|
||||
df = pytrends.interest_by_region(resolution='COUNTRY', inc_low_vol=True, inc_geo_code=False)
|
||||
if df.empty:
|
||||
elapsed = int((time.monotonic() - start) * 1000)
|
||||
if df is None or (hasattr(df, 'empty') and df.empty):
|
||||
logger.info(f"[Trends] interest_by_region returned empty in {elapsed}ms")
|
||||
return []
|
||||
return self._format_dataframe(df.reset_index())
|
||||
result = self._format_dataframe(df.reset_index(), keywords or pytrends.kw_list)
|
||||
logger.info(f"[Trends] interest_by_region returned {len(result)} regions in {elapsed}ms")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching interest by region: {e}")
|
||||
elapsed = int((time.monotonic() - start) * 1000)
|
||||
logger.error(f"[Trends] interest_by_region failed in {elapsed}ms: {e}")
|
||||
return []
|
||||
|
||||
def _safe_related_topics(
|
||||
self,
|
||||
pytrends: TrendReq,
|
||||
keywords: List[str]
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Safely fetch related topics."""
|
||||
def _fetch_related_topics(self, pytrends: _TrendReq) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Fetch related topics. Patches catch IndexError from pytrends bug."""
|
||||
start = time.monotonic()
|
||||
result = {"top": [], "rising": []}
|
||||
try:
|
||||
topics_data = pytrends.related_topics()
|
||||
result = {"top": [], "rising": []}
|
||||
elapsed = int((time.monotonic() - start) * 1000)
|
||||
|
||||
for keyword in keywords:
|
||||
if keyword in topics_data and isinstance(topics_data[keyword], dict):
|
||||
keyword_topics = topics_data[keyword]
|
||||
if topics_data is None:
|
||||
logger.info(f"[Trends] related_topics returned None in {elapsed}ms")
|
||||
return result
|
||||
|
||||
if "top" in keyword_topics and not keyword_topics["top"].empty:
|
||||
top_df = keyword_topics["top"]
|
||||
# Select relevant columns
|
||||
if "topic_title" in top_df.columns and "value" in top_df.columns:
|
||||
top_data = top_df[["topic_title", "value"]].to_dict('records')
|
||||
result["top"].extend(top_data)
|
||||
if not isinstance(topics_data, dict):
|
||||
logger.info(f"[Trends] related_topics returned {type(topics_data).__name__}, expected dict")
|
||||
return result
|
||||
|
||||
if "rising" in keyword_topics and not keyword_topics["rising"].empty:
|
||||
rising_df = keyword_topics["rising"]
|
||||
if "topic_title" in rising_df.columns and "value" in rising_df.columns:
|
||||
rising_data = rising_df[["topic_title", "value"]].to_dict('records')
|
||||
result["rising"].extend(rising_data)
|
||||
for key, keyword_data in topics_data.items():
|
||||
if keyword_data is None or not isinstance(keyword_data, dict):
|
||||
continue
|
||||
|
||||
for section in ["top", "rising"]:
|
||||
section_df = keyword_data.get(section)
|
||||
if section_df is None:
|
||||
continue
|
||||
if hasattr(section_df, 'empty') and section_df.empty:
|
||||
continue
|
||||
if not hasattr(section_df, 'to_dict'):
|
||||
continue
|
||||
|
||||
try:
|
||||
if "topic_title" in section_df.columns and "value" in section_df.columns:
|
||||
data = section_df[["topic_title", "value"]].to_dict('records')
|
||||
else:
|
||||
data = section_df.to_dict('records')
|
||||
result[section].extend(data)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing {section} topics for key '{key}': {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"[Trends] related_topics completed in {elapsed}ms, top={len(result['top'])} rising={len(result['rising'])}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching related topics: {e}")
|
||||
return {"top": [], "rising": []}
|
||||
elapsed = int((time.monotonic() - start) * 1000)
|
||||
logger.error(f"[Trends] related_topics failed in {elapsed}ms: {e}")
|
||||
return result
|
||||
|
||||
def _safe_related_queries(
|
||||
self,
|
||||
pytrends: TrendReq,
|
||||
keywords: List[str]
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Safely fetch related queries."""
|
||||
def _fetch_related_queries(self, pytrends: _TrendReq) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Fetch related queries. Patches catch IndexError from pytrends bug."""
|
||||
start = time.monotonic()
|
||||
result = {"top": [], "rising": []}
|
||||
try:
|
||||
queries_data = pytrends.related_queries()
|
||||
result = {"top": [], "rising": []}
|
||||
elapsed = int((time.monotonic() - start) * 1000)
|
||||
|
||||
for keyword in keywords:
|
||||
if keyword in queries_data and isinstance(queries_data[keyword], dict):
|
||||
keyword_queries = queries_data[keyword]
|
||||
if queries_data is None:
|
||||
logger.info(f"[Trends] related_queries returned None in {elapsed}ms")
|
||||
return result
|
||||
|
||||
if "top" in keyword_queries and not keyword_queries["top"].empty:
|
||||
top_df = keyword_queries["top"]
|
||||
result["top"].extend(top_df.to_dict('records'))
|
||||
if not isinstance(queries_data, dict):
|
||||
logger.info(f"[Trends] related_queries returned {type(queries_data).__name__}, expected dict")
|
||||
return result
|
||||
|
||||
if "rising" in keyword_queries and not keyword_queries["rising"].empty:
|
||||
rising_df = keyword_queries["rising"]
|
||||
result["rising"].extend(rising_df.to_dict('records'))
|
||||
for key, keyword_data in queries_data.items():
|
||||
if keyword_data is None or not isinstance(keyword_data, dict):
|
||||
continue
|
||||
|
||||
for section in ["top", "rising"]:
|
||||
section_df = keyword_data.get(section)
|
||||
if section_df is None:
|
||||
continue
|
||||
if hasattr(section_df, 'empty') and section_df.empty:
|
||||
continue
|
||||
if not hasattr(section_df, 'to_dict'):
|
||||
continue
|
||||
|
||||
try:
|
||||
data = section_df.to_dict('records')
|
||||
result[section].extend(data)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing {section} queries for key '{key}': {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"[Trends] related_queries completed in {elapsed}ms, top={len(result['top'])} rising={len(result['rising'])}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching related queries: {e}")
|
||||
return {"top": [], "rising": []}
|
||||
elapsed = int((time.monotonic() - start) * 1000)
|
||||
logger.error(f"[Trends] related_queries failed in {elapsed}ms: {e}")
|
||||
return result
|
||||
|
||||
def _format_dataframe(self, df: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||
"""Convert DataFrame to list of dicts (serializable format)."""
|
||||
# -----------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _format_dataframe(self, df: pd.DataFrame, keywords: List[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Convert DataFrame to list of dicts. Handles both pytrends and SerpAPI formats."""
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
# Convert datetime columns to strings
|
||||
for col in df.columns:
|
||||
if pd.api.types.is_datetime64_any_dtype(df[col]):
|
||||
df[col] = df[col].astype(str)
|
||||
# Try to detect and handle SerpAPI-style nested data
|
||||
# Check if the dataframe has 'date' column and 'values' array column
|
||||
records = df.to_dict('records')
|
||||
|
||||
# Convert to dict records
|
||||
return df.to_dict('records')
|
||||
# Check first record for nested values pattern (SerpAPI format)
|
||||
if records and 'values' in records[0] and isinstance(records[0]['values'], list):
|
||||
# SerpAPI-style: need to flatten
|
||||
flat_records = []
|
||||
for record in records:
|
||||
date_str = record.get('date', '')
|
||||
timestamp = record.get('timestamp', '')
|
||||
is_partial = record.get('partial_data', False)
|
||||
|
||||
# Extract values from nested array
|
||||
for val_entry in record['values']:
|
||||
keyword_name = val_entry.get('query', '')
|
||||
value = val_entry.get('value', val_entry.get('extracted_value', 0))
|
||||
flat_record = {
|
||||
'date': date_str,
|
||||
'timestamp': timestamp,
|
||||
keyword_name: int(value) if value else 0,
|
||||
}
|
||||
if is_partial:
|
||||
flat_record['isPartial'] = True
|
||||
flat_records.append(flat_record)
|
||||
records = flat_records
|
||||
|
||||
# Convert datetime columns to strings
|
||||
for record in records:
|
||||
for key, value in record.items():
|
||||
if hasattr(value, 'year'): # datetime-like
|
||||
record[key] = str(value)
|
||||
|
||||
return records
|
||||
|
||||
def _build_cache_key(self, keywords: List[str], timeframe: str, geo: str) -> str:
|
||||
"""Build cache key from parameters."""
|
||||
keywords_str = ":".join(sorted(keywords))
|
||||
return f"google_trends:{keywords_str}:{timeframe}:{geo}"
|
||||
|
||||
def _get_from_cache(self, cache_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache if not expired."""
|
||||
if cache_key not in self.cache:
|
||||
return None
|
||||
|
||||
cached_entry = self.cache[cache_key]
|
||||
cached_time = datetime.fromisoformat(cached_entry.get("timestamp", ""))
|
||||
|
||||
if datetime.utcnow() - cached_time > self.cache_ttl:
|
||||
# Expired, remove from cache
|
||||
del self.cache[cache_key]
|
||||
return None
|
||||
|
||||
# Return cached data (without cached flag)
|
||||
result = {**cached_entry}
|
||||
result.pop("cached", None)
|
||||
return result
|
||||
|
||||
def _save_to_cache(self, cache_key: str, data: Dict[str, Any]):
|
||||
"""Save data to cache."""
|
||||
# Store with timestamp
|
||||
cache_entry = {
|
||||
**data,
|
||||
"cached_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
cache_entry = {**data, "cached_at": datetime.utcnow().isoformat()}
|
||||
self.cache[cache_key] = cache_entry
|
||||
|
||||
# Clean up old cache entries periodically
|
||||
if len(self.cache) > 100: # Limit cache size
|
||||
if len(self.cache) > 100:
|
||||
self._cleanup_cache()
|
||||
|
||||
def _cleanup_cache(self):
|
||||
"""Remove expired cache entries."""
|
||||
now = datetime.utcnow()
|
||||
expired_keys = []
|
||||
|
||||
for key, entry in self.cache.items():
|
||||
cached_time = datetime.fromisoformat(entry.get("cached_at", entry.get("timestamp", "")))
|
||||
if now - cached_time > self.cache_ttl:
|
||||
expired_keys.append(key)
|
||||
|
||||
for key in expired_keys:
|
||||
del self.cache[key]
|
||||
|
||||
logger.debug(f"Cleaned up {len(expired_keys)} expired cache entries")
|
||||
|
||||
def _create_fallback_response(
|
||||
@@ -330,9 +537,10 @@ class GoogleTrendsService:
|
||||
keywords: List[str],
|
||||
timeframe: str,
|
||||
geo: str,
|
||||
error_message: str
|
||||
gprop: str = "",
|
||||
error_message: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Create fallback response when trends analysis fails."""
|
||||
source = "web" if gprop == "" else "podcast" if gprop == "youtube" else gprop
|
||||
return {
|
||||
"interest_over_time": [],
|
||||
"interest_by_region": [],
|
||||
@@ -341,38 +549,36 @@ class GoogleTrendsService:
|
||||
"timeframe": timeframe,
|
||||
"geo": geo,
|
||||
"keywords": keywords,
|
||||
"source": source,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"cached": False,
|
||||
"error": error_message
|
||||
"error": error_message,
|
||||
}
|
||||
|
||||
async def get_trending_searches(
|
||||
self,
|
||||
country: str = "united_states",
|
||||
user_id: Optional[str] = None
|
||||
user_id: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Get current trending searches for a country.
|
||||
|
||||
Args:
|
||||
country: Country name (e.g., "united_states", "united_kingdom")
|
||||
user_id: User ID for subscription checks
|
||||
|
||||
Returns:
|
||||
List of trending search terms
|
||||
"""
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
try:
|
||||
pytrends = TrendReq(hl='en-US', tz=360)
|
||||
ua = random.choice(self.USER_AGENTS)
|
||||
pytrends = _TrendReq(
|
||||
hl='en-US',
|
||||
tz=360,
|
||||
timeout=(10, 30),
|
||||
retries=0,
|
||||
backoff_factor=0,
|
||||
requests_args={'headers': {'User-Agent': ua}},
|
||||
)
|
||||
trending_df = await asyncio.to_thread(
|
||||
lambda: pytrends.trending_searches(pn=country)
|
||||
)
|
||||
|
||||
if trending_df.empty:
|
||||
if trending_df is None or (hasattr(trending_df, 'empty') and trending_df.empty):
|
||||
return []
|
||||
|
||||
# Return as list of strings
|
||||
return trending_df[0].tolist() if len(trending_df.columns) > 0 else []
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -19,6 +19,15 @@ if TYPE_CHECKING:
|
||||
from .pricing_service import PricingService
|
||||
|
||||
|
||||
def _should_enforce_limit(limit_value: int, tier: str) -> bool:
|
||||
"""
|
||||
Determine if a limit should be enforced.
|
||||
- Free tier: 0 means DISABLED (not unlimited)
|
||||
- Basic/Pro/Enterprise: 0 means UNLIMITED
|
||||
"""
|
||||
return limit_value > 0
|
||||
|
||||
|
||||
class LimitValidator:
|
||||
"""Validates subscription limits for API usage."""
|
||||
|
||||
@@ -144,6 +153,9 @@ class LimitValidator:
|
||||
logger.warning(f"[Subscription Check] No subscription or free tier found for user {user_id}, denying access")
|
||||
return False, "No subscription plan found. Please subscribe to a plan.", {}
|
||||
|
||||
# Extract tier for limit enforcement logic
|
||||
user_tier = limits.get('tier', 'free') if limits else 'free'
|
||||
|
||||
# Get current usage for this billing period with error handling
|
||||
# Use targeted expiry instead of expire_all() to avoid nuking the entire session cache
|
||||
try:
|
||||
@@ -245,8 +257,8 @@ class LimitValidator:
|
||||
(usage.mistral_calls or 0)
|
||||
)
|
||||
|
||||
# Only enforce limit if limit > 0 (0 means unlimited for Enterprise)
|
||||
if ai_text_gen_limit > 0 and current_total_llm_calls >= ai_text_gen_limit:
|
||||
# Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
|
||||
if _should_enforce_limit(ai_text_gen_limit, user_tier) and current_total_llm_calls >= ai_text_gen_limit:
|
||||
logger.error(f"[Subscription Check] AI text generation call limit exceeded for user {user_id}: {current_total_llm_calls}/{ai_text_gen_limit} (provider: {display_provider_name})")
|
||||
result = (False, f"AI text generation call limit reached. Used {current_total_llm_calls} of {ai_text_gen_limit} total AI text generation calls this billing period.", {
|
||||
'current_calls': current_total_llm_calls,
|
||||
@@ -278,8 +290,8 @@ class LimitValidator:
|
||||
current_calls = getattr(usage, f"{provider_name}_calls", 0) or 0
|
||||
call_limit = limits['limits'].get(f"{provider_name}_calls", 0) or 0
|
||||
|
||||
# Only enforce limit if limit > 0 (0 means unlimited for Enterprise)
|
||||
if call_limit > 0 and current_calls >= call_limit:
|
||||
# Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
|
||||
if _should_enforce_limit(call_limit, user_tier) and current_calls >= call_limit:
|
||||
logger.error(f"[Subscription Check] Call limit exceeded for user {user_id}, provider {display_provider_name}: {current_calls}/{call_limit}")
|
||||
result = (False, f"API call limit reached for {display_provider_name}. Used {current_calls} of {call_limit} calls this billing period.", {
|
||||
'current_calls': current_calls,
|
||||
@@ -296,7 +308,13 @@ class LimitValidator:
|
||||
logger.debug(f"[Subscription Check] Call limit check passed for user {user_id}, provider {display_provider_name}: {current_calls}/{call_limit if call_limit > 0 else 'unlimited'}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking call limits: {e}")
|
||||
# Continue to next check
|
||||
# Fail closed - deny if we can't verify the limit
|
||||
result = (False, f"Unable to verify call limit: {str(e)}", {})
|
||||
self.pricing_service._limits_cache[cache_key] = {
|
||||
'result': result,
|
||||
'expires_at': now + timedelta(seconds=30)
|
||||
}
|
||||
return result
|
||||
|
||||
# Check token limits for LLM providers with error handling
|
||||
# NOTE: token_limit = 0 means UNLIMITED (Enterprise plans)
|
||||
@@ -305,8 +323,8 @@ class LimitValidator:
|
||||
current_tokens = getattr(usage, f"{provider_name}_tokens", 0) or 0
|
||||
token_limit = limits['limits'].get(f"{provider_name}_tokens", 0) or 0
|
||||
|
||||
# Only enforce limit if limit > 0 (0 means unlimited for Enterprise)
|
||||
if token_limit > 0 and (current_tokens + tokens_requested) > token_limit:
|
||||
# Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
|
||||
if _should_enforce_limit(token_limit, user_tier) and (current_tokens + tokens_requested) > token_limit:
|
||||
result = (False, f"Token limit would be exceeded for {display_provider_name}. Current: {current_tokens}, Requested: {tokens_requested}, Limit: {token_limit}", {
|
||||
'current_tokens': current_tokens,
|
||||
'requested_tokens': tokens_requested,
|
||||
@@ -328,14 +346,19 @@ class LimitValidator:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking token limits: {e}")
|
||||
# Continue to next check
|
||||
# Fail closed - deny if we can't verify the limit
|
||||
result = (False, f"Unable to verify token limit: {str(e)}", {})
|
||||
self.pricing_service._limits_cache[cache_key] = {
|
||||
'result': result,
|
||||
'expires_at': now + timedelta(seconds=30)
|
||||
}
|
||||
return result
|
||||
|
||||
# Check cost limits with error handling
|
||||
# NOTE: cost_limit = 0 means UNLIMITED (Enterprise plans)
|
||||
try:
|
||||
cost_limit = limits['limits'].get('monthly_cost', 0) or 0
|
||||
# Only enforce limit if limit > 0 (0 means unlimited for Enterprise)
|
||||
if cost_limit > 0 and usage.total_cost >= cost_limit:
|
||||
# Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
|
||||
if _should_enforce_limit(cost_limit, user_tier) and usage.total_cost >= cost_limit:
|
||||
result = (False, f"Monthly cost limit reached. Current cost: ${usage.total_cost:.2f}, Limit: ${cost_limit:.2f}", {
|
||||
'current_cost': usage.total_cost,
|
||||
'limit': cost_limit,
|
||||
@@ -348,7 +371,13 @@ class LimitValidator:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking cost limits: {e}")
|
||||
# Continue to success case
|
||||
# Fail closed - deny if we can't verify the limit
|
||||
result = (False, f"Unable to verify cost limit: {str(e)}", {})
|
||||
self.pricing_service._limits_cache[cache_key] = {
|
||||
'result': result,
|
||||
'expires_at': now + timedelta(seconds=30)
|
||||
}
|
||||
return result
|
||||
|
||||
# Calculate usage percentages for warnings
|
||||
try:
|
||||
@@ -503,6 +532,7 @@ class LimitValidator:
|
||||
return False, "No subscription plan found. Please subscribe to a plan.", {}
|
||||
|
||||
limits = limits_dict.get('limits', {})
|
||||
tier = limits_dict.get('tier', 'free')
|
||||
|
||||
# Track cumulative usage across all operations
|
||||
total_llm_calls = (
|
||||
@@ -547,7 +577,8 @@ class LimitValidator:
|
||||
# Count this operation as an LLM call
|
||||
projected_total_llm_calls = total_llm_calls + 1
|
||||
|
||||
if ai_text_gen_limit > 0 and projected_total_llm_calls > ai_text_gen_limit:
|
||||
# Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
|
||||
if _should_enforce_limit(ai_text_gen_limit, tier) and projected_total_llm_calls > ai_text_gen_limit:
|
||||
error_info = {
|
||||
'current_calls': total_llm_calls,
|
||||
'limit': ai_text_gen_limit,
|
||||
@@ -654,7 +685,8 @@ class LimitValidator:
|
||||
|
||||
token_limit = limits.get(provider_tokens_key, 0) or 0
|
||||
|
||||
if token_limit > 0 and tokens_requested > 0:
|
||||
# Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
|
||||
if _should_enforce_limit(token_limit, tier) and tokens_requested > 0:
|
||||
projected_tokens = current_provider_tokens + tokens_requested
|
||||
logger.info(f" └─ Token Check: {current_provider_tokens} (current) + {tokens_requested} (requested) = {projected_tokens} (total) / {token_limit} (limit)")
|
||||
|
||||
@@ -716,7 +748,8 @@ class LimitValidator:
|
||||
image_limit = limits.get('stability_calls', 0) or 0
|
||||
projected_images = total_images + 1
|
||||
|
||||
if image_limit > 0 and projected_images > image_limit:
|
||||
# Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
|
||||
if _should_enforce_limit(image_limit, tier) and projected_images > image_limit:
|
||||
error_info = {
|
||||
'current_images': total_images,
|
||||
'limit': image_limit,
|
||||
@@ -737,7 +770,8 @@ class LimitValidator:
|
||||
total_video_calls = usage.video_calls or 0
|
||||
projected_video_calls = total_video_calls + 1
|
||||
|
||||
if video_limit > 0 and projected_video_calls > video_limit:
|
||||
# Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
|
||||
if _should_enforce_limit(video_limit, tier) and projected_video_calls > video_limit:
|
||||
error_info = {
|
||||
'current_calls': total_video_calls,
|
||||
'limit': video_limit,
|
||||
@@ -756,7 +790,8 @@ class LimitValidator:
|
||||
total_image_edit_calls = getattr(usage, 'image_edit_calls', 0) or 0
|
||||
projected_image_edit_calls = total_image_edit_calls + 1
|
||||
|
||||
if image_edit_limit > 0 and projected_image_edit_calls > image_edit_limit:
|
||||
# Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
|
||||
if _should_enforce_limit(image_edit_limit, tier) and projected_image_edit_calls > image_edit_limit:
|
||||
error_info = {
|
||||
'current_calls': total_image_edit_calls,
|
||||
'limit': image_edit_limit,
|
||||
@@ -790,6 +825,25 @@ class LimitValidator:
|
||||
'usage_info': error_info
|
||||
}
|
||||
|
||||
# Check WaveSpeed combined limit if actual_provider is WaveSpeed
|
||||
if actual_provider_name == 'wavespeed':
|
||||
wavespeed_limit = limits.get('wavespeed_calls', 0) or 0
|
||||
if _should_enforce_limit(wavespeed_limit, tier):
|
||||
wavespeed_usage = usage.wavespeed_calls or 0
|
||||
projected_wavespeed = wavespeed_usage + 1
|
||||
if projected_wavespeed > wavespeed_limit:
|
||||
error_info = {
|
||||
'current_calls': wavespeed_usage,
|
||||
'limit': wavespeed_limit,
|
||||
'provider': 'wavespeed',
|
||||
'operation_type': operation_type,
|
||||
'operation_index': op_idx
|
||||
}
|
||||
return False, f"WaveSpeed API limit would be exceeded. Would use {projected_wavespeed} of {wavespeed_limit} WaveSpeed calls this billing period.", {
|
||||
'error_type': 'wavespeed_limit',
|
||||
'usage_info': error_info
|
||||
}
|
||||
|
||||
# All checks passed
|
||||
logger.info(f"[Pre-flight Check] ✅ All {len(operations)} operation(s) validated successfully")
|
||||
logger.info(f"[Pre-flight Check] ✅ User {user_id} is cleared to proceed with API calls")
|
||||
|
||||
@@ -494,7 +494,16 @@ class PricingService:
|
||||
logger.debug(f"Added new pricing for {pricing_data['provider'].value}:{pricing_data['model_name']}")
|
||||
|
||||
self.db.commit()
|
||||
logger.info("Default API pricing initialized/updated. HuggingFace pricing loaded from env vars if available.")
|
||||
|
||||
# Debug: count pricing rows seeded
|
||||
total_rows = self.db.query(APIProviderPricing).count()
|
||||
providers = self.db.query(APIProviderPricing.provider).distinct().all()
|
||||
provider_list = sorted([p[0].value for p in providers]) if providers else []
|
||||
logger.info(f"[PRICING_INIT] Default API pricing initialized: {len(all_pricing)} rows configured, {total_rows} rows in DB, providers: {provider_list}")
|
||||
|
||||
# Warning-level log that will be visible
|
||||
logger.warning(f"[PRICING_INIT] Pricing ready: {total_rows} rows for {len(provider_list)} providers")
|
||||
logger.warning("Default API pricing initialized/updated. HuggingFace pricing loaded from env vars if available.")
|
||||
|
||||
def initialize_default_plans(self):
|
||||
"""Initialize default subscription plans."""
|
||||
@@ -505,21 +514,26 @@ class PricingService:
|
||||
"tier": SubscriptionTier.FREE,
|
||||
"price_monthly": 0.0,
|
||||
"price_yearly": 0.0,
|
||||
"gemini_calls_limit": 100,
|
||||
"openai_calls_limit": 0,
|
||||
"anthropic_calls_limit": 0,
|
||||
"mistral_calls_limit": 50,
|
||||
"tavily_calls_limit": 20,
|
||||
"serper_calls_limit": 20,
|
||||
"metaphor_calls_limit": 10,
|
||||
"firecrawl_calls_limit": 10,
|
||||
"stability_calls_limit": 5,
|
||||
"exa_calls_limit": 100,
|
||||
"video_calls_limit": 0, # No video generation for free tier
|
||||
"image_edit_calls_limit": 10, # 10 AI image editing calls/month
|
||||
"audio_calls_limit": 20, # 20 AI audio generation calls/month
|
||||
"gemini_tokens_limit": 100000,
|
||||
"monthly_cost_limit": 0.0,
|
||||
"ai_text_generation_calls_limit": 50, # Explicit: Free gets 50 AI text calls (via Gemini fallback)
|
||||
"gemini_calls_limit": 50,
|
||||
"openai_calls_limit": 0, # DISABLED: OpenAI access not included in Free tier
|
||||
"anthropic_calls_limit": 0, # DISABLED: Anthropic access not included in Free tier
|
||||
"mistral_calls_limit": 0, # DISABLED: HuggingFace not in Free tier
|
||||
"tavily_calls_limit": 10,
|
||||
"serper_calls_limit": 10,
|
||||
"metaphor_calls_limit": 0, # DISABLED: Metaphor not in Free tier
|
||||
"firecrawl_calls_limit": 0, # DISABLED: Firecrawl not in Free tier
|
||||
"stability_calls_limit": 3, # 3 images - enough to try the product
|
||||
"exa_calls_limit": 10, # 10 research queries - enough to try the product
|
||||
"video_calls_limit": 0, # DISABLED: Video generation not in Free tier
|
||||
"image_edit_calls_limit": 5, # 5 image edits - enough to try the product
|
||||
"audio_calls_limit": 5, # 5 audio clips - enough to try the product
|
||||
"wavespeed_calls_limit": 0, # DISABLED: WaveSpeed not included in Free tier
|
||||
"gemini_tokens_limit": 50000,
|
||||
"openai_tokens_limit": 0, # DISABLED
|
||||
"anthropic_tokens_limit": 0, # DISABLED
|
||||
"mistral_tokens_limit": 0, # DISABLED
|
||||
"monthly_cost_limit": 2.0, # $2 cap - prevents runaway costs on free tier
|
||||
"features": ["basic_content_generation", "limited_research"],
|
||||
"description": "Perfect for trying out ALwrity"
|
||||
},
|
||||
@@ -528,7 +542,7 @@ class PricingService:
|
||||
"tier": SubscriptionTier.BASIC,
|
||||
"price_monthly": 29.0,
|
||||
"price_yearly": 290.0,
|
||||
"ai_text_generation_calls_limit": 50, # INCREASED: Unified limit for all LLM providers (OSS-focused strategy)
|
||||
"ai_text_generation_calls_limit": 500, # Unified limit for all LLM providers
|
||||
"gemini_calls_limit": 1000, # Legacy, kept for backwards compatibility (not used for enforcement)
|
||||
"openai_calls_limit": 500,
|
||||
"anthropic_calls_limit": 200,
|
||||
@@ -537,16 +551,17 @@ class PricingService:
|
||||
"serper_calls_limit": 200,
|
||||
"metaphor_calls_limit": 100,
|
||||
"firecrawl_calls_limit": 100,
|
||||
"stability_calls_limit": 50, # INCREASED: Now includes WaveSpeed OSS models (Qwen Image $0.03)
|
||||
"exa_calls_limit": 500,
|
||||
"video_calls_limit": 30, # INCREASED: 30 videos/month (WAN 2.5 OSS $0.25)
|
||||
"image_edit_calls_limit": 50, # INCREASED: 50 AI image editing calls/month (Qwen Edit OSS $0.02)
|
||||
"stability_calls_limit": 25, # 25 images - good for podcast episode covers
|
||||
"exa_calls_limit": 100, # 100 research queries
|
||||
"video_calls_limit": 10, # 10 videos - enough for a few podcast episodes
|
||||
"image_edit_calls_limit": 25, # 25 AI image edits
|
||||
"audio_calls_limit": 100, # INCREASED: 100 AI audio generation calls/month (Minimax Speech OSS)
|
||||
"wavespeed_calls_limit": 200, # WaveSpeed combined limit: TTS + video + image + LLM (Minimax Speech $0.002/min, Qwen $0.03/img, Kling $0.25/5s)
|
||||
"gemini_tokens_limit": 100000, # INCREASED: 100K tokens per provider (OSS-focused strategy)
|
||||
"openai_tokens_limit": 100000, # INCREASED: 100K tokens per provider
|
||||
"anthropic_tokens_limit": 100000, # INCREASED: 100K tokens per provider
|
||||
"mistral_tokens_limit": 100000, # INCREASED: 100K tokens per provider
|
||||
"monthly_cost_limit": 45.0, # ADJUSTED: $45 cap (aligns with $40-50 hard limit target)
|
||||
"monthly_cost_limit": 25.0, # $25 cap - podcast-focused pricing
|
||||
"features": ["full_content_generation", "advanced_research", "basic_analytics", "all_tools_access", "oss_models_priority"],
|
||||
"description": "Perfect for individuals and small teams. Access all ALwrity features with generous limits powered by OSS AI models."
|
||||
},
|
||||
@@ -555,6 +570,7 @@ class PricingService:
|
||||
"tier": SubscriptionTier.PRO,
|
||||
"price_monthly": 79.0,
|
||||
"price_yearly": 790.0,
|
||||
"ai_text_generation_calls_limit": 3000, # Explicit: Pro gets 3000 AI text calls
|
||||
"gemini_calls_limit": 5000,
|
||||
"openai_calls_limit": 2500,
|
||||
"anthropic_calls_limit": 1000,
|
||||
@@ -563,16 +579,17 @@ class PricingService:
|
||||
"serper_calls_limit": 1000,
|
||||
"metaphor_calls_limit": 500,
|
||||
"firecrawl_calls_limit": 500,
|
||||
"stability_calls_limit": 200,
|
||||
"exa_calls_limit": 2000,
|
||||
"video_calls_limit": 50, # 50 videos/month for pro plan
|
||||
"image_edit_calls_limit": 100, # 100 AI image editing calls/month
|
||||
"audio_calls_limit": 200, # 200 AI audio generation calls/month
|
||||
"stability_calls_limit": 100, # 100 images - good for regular podcasts
|
||||
"exa_calls_limit": 500, # 500 research queries
|
||||
"video_calls_limit": 30, # 30 videos - enough for daily episodes
|
||||
"image_edit_calls_limit": 100, # 100 AI image edits
|
||||
"audio_calls_limit": 100, # 100 audio clips - podcast-focused
|
||||
"wavespeed_calls_limit": 500, # WaveSpeed combined limit: TTS + video + image + LLM
|
||||
"gemini_tokens_limit": 5000000,
|
||||
"openai_tokens_limit": 2500000,
|
||||
"anthropic_tokens_limit": 1000000,
|
||||
"mistral_tokens_limit": 2500000,
|
||||
"monthly_cost_limit": 150.0,
|
||||
"monthly_cost_limit": 100.0, # $100 cap - podcast-focused
|
||||
"features": ["unlimited_content_generation", "premium_research", "advanced_analytics", "priority_support"],
|
||||
"description": "Perfect for growing businesses"
|
||||
},
|
||||
@@ -581,6 +598,7 @@ class PricingService:
|
||||
"tier": SubscriptionTier.ENTERPRISE,
|
||||
"price_monthly": 199.0,
|
||||
"price_yearly": 1990.0,
|
||||
"ai_text_generation_calls_limit": 0, # Unlimited
|
||||
"gemini_calls_limit": 0, # Unlimited
|
||||
"openai_calls_limit": 0,
|
||||
"anthropic_calls_limit": 0,
|
||||
@@ -594,6 +612,7 @@ class PricingService:
|
||||
"video_calls_limit": 0, # Unlimited for enterprise
|
||||
"image_edit_calls_limit": 0, # Unlimited image editing for enterprise
|
||||
"audio_calls_limit": 0, # Unlimited audio generation for enterprise
|
||||
"wavespeed_calls_limit": 0, # Unlimited for enterprise
|
||||
"gemini_tokens_limit": 0,
|
||||
"openai_tokens_limit": 0,
|
||||
"anthropic_tokens_limit": 0,
|
||||
@@ -815,6 +834,7 @@ class PricingService:
|
||||
'video_calls': getattr(plan, 'video_calls_limit', 0), # Support missing column
|
||||
'image_edit_calls': getattr(plan, 'image_edit_calls_limit', 0), # Support missing column
|
||||
'audio_calls': getattr(plan, 'audio_calls_limit', 0), # Support missing column
|
||||
'wavespeed_calls': getattr(plan, 'wavespeed_calls_limit', 0), # WaveSpeed API calls
|
||||
# Token limits
|
||||
'gemini_tokens': plan.gemini_tokens_limit,
|
||||
'openai_tokens': plan.openai_tokens_limit,
|
||||
|
||||
@@ -29,10 +29,12 @@ def ensure_subscription_plan_columns(db: Session) -> None:
|
||||
|
||||
# Columns we may reference in models but might be missing in older DBs
|
||||
required_columns = {
|
||||
"ai_text_generation_calls_limit": "INTEGER DEFAULT 0",
|
||||
"exa_calls_limit": "INTEGER DEFAULT 0",
|
||||
"video_calls_limit": "INTEGER DEFAULT 0",
|
||||
"image_edit_calls_limit": "INTEGER DEFAULT 0",
|
||||
"audio_calls_limit": "INTEGER DEFAULT 0",
|
||||
"wavespeed_calls_limit": "INTEGER DEFAULT 0",
|
||||
}
|
||||
|
||||
for col_name, ddl in required_columns.items():
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from loguru import logger
|
||||
import json
|
||||
from api.subscription.cache import clear_dashboard_cache
|
||||
|
||||
from models.subscription_models import (
|
||||
APIUsageLog, UsageSummary, APIProvider, UsageAlert,
|
||||
@@ -44,12 +45,12 @@ class UsageTrackingService:
|
||||
self._enforce_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _get_authoritative_billing_period_keys(self, user_id: str, billing_period: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Return authoritative billing period lookup keys anchored to subscription period boundaries."""
|
||||
"""Return authoritative billing period lookup keys. Always uses calendar month for consistency."""
|
||||
subscription = self.db.query(UserSubscription).filter(
|
||||
UserSubscription.user_id == user_id
|
||||
).first()
|
||||
|
||||
# If caller explicitly requested a billing period, keep it authoritative for that read.
|
||||
# If caller explicitly requested a billing period, use it
|
||||
if billing_period:
|
||||
return {
|
||||
"billing_period": billing_period,
|
||||
@@ -58,23 +59,15 @@ class UsageTrackingService:
|
||||
"period_end": subscription.current_period_end if subscription else None,
|
||||
}
|
||||
|
||||
if subscription and subscription.current_period_start and subscription.current_period_end:
|
||||
start_key = subscription.current_period_start.strftime("%Y-%m")
|
||||
end_key = subscription.current_period_end.strftime("%Y-%m")
|
||||
lookup_periods = [start_key] if start_key == end_key else [start_key, end_key]
|
||||
return {
|
||||
"billing_period": start_key,
|
||||
"lookup_periods": lookup_periods,
|
||||
"period_start": subscription.current_period_start,
|
||||
"period_end": subscription.current_period_end,
|
||||
}
|
||||
# ALWAYS use current calendar month for billing period to ensure consistency
|
||||
# This prevents data loss when subscription spans month boundaries
|
||||
current_period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
resolved_period = self.pricing_service.get_current_billing_period(user_id) or datetime.now().strftime("%Y-%m")
|
||||
return {
|
||||
"billing_period": resolved_period,
|
||||
"lookup_periods": [resolved_period],
|
||||
"period_start": None,
|
||||
"period_end": None,
|
||||
"billing_period": current_period,
|
||||
"lookup_periods": [current_period],
|
||||
"period_start": subscription.current_period_start if subscription else None,
|
||||
"period_end": subscription.current_period_end if subscription else None,
|
||||
}
|
||||
|
||||
async def track_api_usage(self, user_id: str, provider: APIProvider,
|
||||
@@ -170,6 +163,12 @@ class UsageTrackingService:
|
||||
|
||||
self.db.commit()
|
||||
|
||||
# Invalidate dashboard cache so header stats update immediately
|
||||
try:
|
||||
clear_dashboard_cache(user_id)
|
||||
except Exception as cache_err:
|
||||
logger.debug(f"Could not clear dashboard cache: {cache_err}")
|
||||
|
||||
logger.info(f"Tracked API usage: {user_id} -> {provider.value} -> ${cost_data['cost_total']:.6f}")
|
||||
|
||||
return {
|
||||
@@ -200,11 +199,14 @@ class UsageTrackingService:
|
||||
).first()
|
||||
|
||||
if not summary:
|
||||
logger.info(f"[UsageTracking] Creating new UsageSummary for user={user_id}, period={period_keys['billing_period']}")
|
||||
summary = UsageSummary(
|
||||
user_id=user_id,
|
||||
billing_period=period_keys["billing_period"]
|
||||
)
|
||||
self.db.add(summary)
|
||||
else:
|
||||
logger.debug(f"[UsageTracking] Found existing UsageSummary for user={user_id}, period={summary.billing_period}, calls={summary.total_calls}")
|
||||
|
||||
# Update provider-specific counters
|
||||
provider_name = provider.value
|
||||
@@ -377,12 +379,19 @@ class UsageTrackingService:
|
||||
period_keys = self._get_authoritative_billing_period_keys(user_id, requested_billing_period)
|
||||
billing_period = period_keys["billing_period"]
|
||||
|
||||
logger.debug(f"[get_user_usage_stats] user={user_id}, billing_period={billing_period}, lookup_periods={period_keys['lookup_periods']}")
|
||||
|
||||
# Get usage summary
|
||||
summary = self.db.query(UsageSummary).filter(
|
||||
UsageSummary.user_id == user_id,
|
||||
UsageSummary.billing_period.in_(period_keys["lookup_periods"])
|
||||
).first()
|
||||
|
||||
if summary:
|
||||
logger.debug(f"[get_user_usage_stats] Found summary: period={summary.billing_period}, calls={summary.total_calls}, cost={summary.total_cost}")
|
||||
else:
|
||||
logger.debug(f"[get_user_usage_stats] No summary found for user={user_id}, period={billing_period}")
|
||||
|
||||
# Get user limits
|
||||
limits = self.pricing_service.get_user_limits(user_id)
|
||||
|
||||
@@ -521,7 +530,6 @@ class UsageTrackingService:
|
||||
|
||||
async def reset_current_billing_period(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Reset usage status and counters for the current billing period (after plan renewal/change)."""
|
||||
try:
|
||||
period_keys = self._get_authoritative_billing_period_keys(user_id)
|
||||
billing_period = period_keys["billing_period"]
|
||||
summary = self.db.query(UsageSummary).filter(
|
||||
@@ -532,9 +540,16 @@ class UsageTrackingService:
|
||||
if not summary:
|
||||
return {"reset": False, "reason": "no_summary"}
|
||||
|
||||
try:
|
||||
reset_usage_summary_counters(summary)
|
||||
self.db.commit()
|
||||
|
||||
# Invalidate dashboard cache so header stats update after reset
|
||||
try:
|
||||
clear_dashboard_cache(user_id)
|
||||
except Exception as cache_err:
|
||||
logger.debug(f"Could not clear dashboard cache: {cache_err}")
|
||||
|
||||
logger.info(f"Reset usage counters for user {user_id} in billing period {billing_period} after renewal")
|
||||
return {"reset": True, "counters_reset": True}
|
||||
except Exception as e:
|
||||
|
||||
@@ -4,6 +4,7 @@ Handles fetching user data from the onboarding database.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
@@ -92,5 +93,88 @@ class UserDataService:
|
||||
return integrated_data.get('website_analysis')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user website analysis: {str(e)}")
|
||||
logger.error(f"Error getting user website analysis: {e}")
|
||||
return None
|
||||
|
||||
def save_website_extraction(self, user_id: str, extraction_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Save website extraction data for future use.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
extraction_data: Website extraction data (title, summary, highlights, url, subpages)
|
||||
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
try:
|
||||
# Clean data - remove images/favicon
|
||||
clean_data = {
|
||||
k: v for k, v in extraction_data.items()
|
||||
if k not in ('image', 'favicon')
|
||||
}
|
||||
clean_data['saved_at'] = datetime.now().isoformat()
|
||||
|
||||
# Find or create user session for storing
|
||||
onboarding = self.db.query(OnboardingSession).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not onboarding:
|
||||
# Create new session if not exists
|
||||
onboarding = OnboardingSession(user_id=user_id)
|
||||
self.db.add(onboarding)
|
||||
|
||||
# Try to update website_analysis field
|
||||
# The field might be JSON in the model
|
||||
try:
|
||||
existing = onboarding.website_analysis
|
||||
if isinstance(existing, dict):
|
||||
existing.update(clean_data)
|
||||
onboarding.website_analysis = existing
|
||||
else:
|
||||
onboarding.website_analysis = clean_data
|
||||
except Exception as ex:
|
||||
logger.warning(f"Could not update website_analysis: {ex}")
|
||||
onboarding.website_analysis = clean_data
|
||||
|
||||
self.db.commit()
|
||||
logger.info(f"Saved website extraction for user {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving website extraction: {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
|
||||
def get_website_extraction(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get saved website extraction data.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
|
||||
Returns:
|
||||
Website extraction data or None
|
||||
"""
|
||||
try:
|
||||
onboarding = self.db.query(OnboardingSession).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not onboarding:
|
||||
return None
|
||||
|
||||
extraction = onboarding.website_analysis
|
||||
if isinstance(extraction, dict):
|
||||
# Return clean data without internal fields
|
||||
return {
|
||||
k: v for k, v in extraction.items()
|
||||
if k not in ('saved_at', 'full_analysis', 'analysis_status')
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting website extraction: {str(e)}")
|
||||
return None
|
||||
|
||||
@@ -481,7 +481,9 @@ class SpeechGenerator:
|
||||
raise HTTPException(status_code=502, detail="WaveSpeed Qwen3 voice clone returned no outputs")
|
||||
|
||||
audio_url = self._extract_audio_url(outputs)
|
||||
return self._download_audio(audio_url, timeout)
|
||||
downloaded_audio = self._download_audio(audio_url, timeout)
|
||||
logger.warning(f"[WaveSpeed] qwen3_voice_clone downloaded {len(downloaded_audio)} bytes")
|
||||
return downloaded_audio
|
||||
|
||||
def cosyvoice_voice_clone(
|
||||
self,
|
||||
|
||||
@@ -3,6 +3,10 @@ Media Utility Functions
|
||||
|
||||
Centralized helper functions for loading and managing media assets across modules.
|
||||
Promotes reuse between Podcast, YouTube, and other media-heavy modules.
|
||||
|
||||
DEPRECATED: The global DATA_MEDIA_DIR paths below are legacy and will be removed.
|
||||
New code should use workspace-scoped paths via utils.storage_paths or module-specific
|
||||
resolvers (e.g., api.podcast.constants.get_podcast_media_dir).
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -12,16 +16,19 @@ from typing import Optional, List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from services.database import WORKSPACE_DIR
|
||||
from utils.storage_paths import get_repo_root
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Base Directories
|
||||
# backend/utils/media_utils.py -> parents[2] = backend/.. = root
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
# Base Directories — use get_repo_root() for consistent resolution
|
||||
ROOT_DIR = get_repo_root()
|
||||
|
||||
# DEPRECATED: Global data/media paths — kept for backward-compat read fallback only.
|
||||
# New writes must go to workspace-scoped paths. Do NOT add new consumers.
|
||||
DATA_MEDIA_DIR = ROOT_DIR / "data" / "media"
|
||||
|
||||
# Module-specific directories
|
||||
# Module-specific directories (DEPRECATED — use workspace-scoped resolvers instead)
|
||||
YOUTUBE_AVATARS_DIR = DATA_MEDIA_DIR / "youtube_avatars"
|
||||
YOUTUBE_IMAGES_DIR = DATA_MEDIA_DIR / "youtube_images"
|
||||
PODCAST_IMAGES_DIR = DATA_MEDIA_DIR / "podcast_images"
|
||||
@@ -33,7 +40,7 @@ def ensure_media_dirs() -> None:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def resolve_media_path(media_url_or_path: str) -> Optional[Path]:
|
||||
def resolve_media_path(media_url_or_path: str, user_id: Optional[str] = None) -> Optional[Path]:
|
||||
"""
|
||||
Resolve a media URL or filename to a concrete file path on disk.
|
||||
|
||||
@@ -41,6 +48,7 @@ def resolve_media_path(media_url_or_path: str) -> Optional[Path]:
|
||||
|
||||
Args:
|
||||
media_url_or_path: URL path (e.g. /api/youtube/avatars/foo.png) or filename
|
||||
user_id: Optional user ID for tenant-scoped resolution (recommended)
|
||||
|
||||
Returns:
|
||||
Path object if found, None otherwise
|
||||
@@ -70,9 +78,9 @@ def resolve_media_path(media_url_or_path: str) -> Optional[Path]:
|
||||
parsed_path = urlparse(media_url_or_path).path
|
||||
parts = parsed_path.split("/")
|
||||
if len(parts) >= 6:
|
||||
user_id = parts[3]
|
||||
safe_user_id = "".join(c for c in user_id if c.isalnum() or c in ("-", "_"))
|
||||
if safe_user_id == user_id:
|
||||
asset_user_id = parts[3]
|
||||
safe_user_id = "".join(c for c in asset_user_id if c.isalnum() or c in ("-", "_"))
|
||||
if safe_user_id == asset_user_id:
|
||||
safe_filename = os.path.basename(filename)
|
||||
assets_path = Path(WORKSPACE_DIR) / f"workspace_{safe_user_id}" / "assets" / "avatars" / safe_filename
|
||||
if assets_path.exists() and assets_path.is_file():
|
||||
@@ -82,7 +90,7 @@ def resolve_media_path(media_url_or_path: str) -> Optional[Path]:
|
||||
logger.error(f"[MediaUtils] Error resolving assets avatar path: {exc}")
|
||||
|
||||
# Define search paths in order of likelihood
|
||||
# We search all avatar/image directories
|
||||
# We search all avatar/image directories (DEPRECATED: global paths — kept for backward-compat reads)
|
||||
search_paths: List[Path] = [
|
||||
YOUTUBE_AVATARS_DIR / filename,
|
||||
PODCAST_AVATARS_DIR / filename,
|
||||
@@ -97,12 +105,21 @@ def resolve_media_path(media_url_or_path: str) -> Optional[Path]:
|
||||
# Prioritize YouTube paths
|
||||
pass # Already first in list
|
||||
elif "/api/podcast/" in media_url_or_path:
|
||||
# Prioritize Podcast paths
|
||||
# Prioritize Podcast paths: use centralized podcast media resolution
|
||||
try:
|
||||
# Import the centralized function that checks tenant workspace first
|
||||
from api.podcast.constants import get_podcast_media_read_dirs
|
||||
podcast_dirs = get_podcast_media_read_dirs("image", user_id=user_id)
|
||||
search_paths = []
|
||||
for pod_dir in podcast_dirs:
|
||||
# Add both avatar and image subdirectories
|
||||
search_paths.append(pod_dir / "avatars" / filename)
|
||||
search_paths.append(pod_dir / filename)
|
||||
except ImportError:
|
||||
# Fallback if podcast constants not available
|
||||
search_paths = [
|
||||
PODCAST_AVATARS_DIR / filename,
|
||||
PODCAST_IMAGES_DIR / filename,
|
||||
YOUTUBE_AVATARS_DIR / filename,
|
||||
YOUTUBE_IMAGES_DIR / filename
|
||||
]
|
||||
|
||||
# Iterate and find first existing file
|
||||
@@ -137,3 +154,89 @@ def load_media_bytes(media_url_or_path: str) -> Optional[bytes]:
|
||||
logger.error(f"[MediaUtils] Error reading file {path}: {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# Audio format magic bytes signatures
|
||||
_AUDIO_SIGNATURES = [
|
||||
(b"\xff\xfb", "mp3"), # MP3 (MPEG-1 Layer 3, common)
|
||||
(b"\xff\xf3", "mp3"), # MP3 (MPEG-2.5 Layer 3)
|
||||
(b"\xff\xf2", "mp3"), # MP3 (MPEG-2 Layer 3)
|
||||
(b"\xff\xfa", "mp3"), # MP3 (MPEG-2 Layer 3 variant)
|
||||
(b"ID3", "mp3"), # MP3 with ID3 tag
|
||||
(b"RIFF", "wav"), # WAV (RIFF header)
|
||||
(b"OggS", "ogg"), # OGG
|
||||
(b"fLaC", "flac"), # FLAC
|
||||
(b"\x1a\x45\xdf\xa3", "webm"), # WebM / Matroska
|
||||
(b"ftyp", "m4a"), # MP4/M4A (ftyp box follows offset 4)
|
||||
]
|
||||
|
||||
|
||||
def detect_audio_format(audio_bytes: bytes) -> tuple[str, str]:
|
||||
"""Detect the actual audio format from content magic bytes.
|
||||
|
||||
Returns:
|
||||
Tuple of (format_name, mime_type).
|
||||
Falls back to ('wav', 'audio/wav') if no signature matches.
|
||||
"""
|
||||
if not audio_bytes or len(audio_bytes) < 4:
|
||||
return "wav", "audio/wav"
|
||||
|
||||
for signature, fmt in _AUDIO_SIGNATURES:
|
||||
if signature == b"ftyp":
|
||||
# M4A/MP4: 'ftyp' appears at offset 4
|
||||
if len(audio_bytes) > 8 and audio_bytes[4:8] == b"ftyp":
|
||||
return "m4a", "audio/mp4"
|
||||
elif audio_bytes[:len(signature)] == signature:
|
||||
mime_map = {
|
||||
"mp3": "audio/mpeg",
|
||||
"wav": "audio/wav",
|
||||
"ogg": "audio/ogg",
|
||||
"flac": "audio/flac",
|
||||
"webm": "audio/webm",
|
||||
"m4a": "audio/mp4",
|
||||
}
|
||||
return fmt, mime_map.get(fmt, "audio/wav")
|
||||
|
||||
# Check for Opus-in-OGG (Opus magic after OGG pages)
|
||||
if b"OpusHead" in audio_bytes[:100]:
|
||||
return "ogg", "audio/ogg"
|
||||
|
||||
# Check for MP4/M4A container (atoms starting with size + type)
|
||||
if len(audio_bytes) > 8:
|
||||
atom_type = audio_bytes[4:8]
|
||||
if atom_type in (b"moov", b"mdat", b"free", b"skip"):
|
||||
return "m4a", "audio/mp4"
|
||||
|
||||
return "wav", "audio/wav"
|
||||
|
||||
|
||||
def ensure_audio_extension(filename: str, audio_bytes: bytes) -> str:
|
||||
"""Adjust filename extension to match the actual audio format in audio_bytes.
|
||||
|
||||
Args:
|
||||
filename: Original filename (may have wrong extension like .wav for mp3 data)
|
||||
audio_bytes: The actual audio data bytes
|
||||
|
||||
Returns:
|
||||
Filename with corrected extension based on content format.
|
||||
"""
|
||||
fmt, _ = detect_audio_format(audio_bytes)
|
||||
ext_map = {
|
||||
"mp3": ".mp3",
|
||||
"wav": ".wav",
|
||||
"ogg": ".ogg",
|
||||
"flac": ".flac",
|
||||
"webm": ".webm",
|
||||
"m4a": ".m4a",
|
||||
"opus": ".ogg",
|
||||
}
|
||||
|
||||
correct_ext = ext_map.get(fmt, ".wav")
|
||||
path = Path(filename)
|
||||
current_ext = path.suffix.lower()
|
||||
|
||||
if current_ext != correct_ext:
|
||||
logger.info(f"[MediaUtils] Correcting audio extension: {filename} -> {path.stem}{correct_ext} (detected format: {fmt})")
|
||||
return f"{path.stem}{correct_ext}"
|
||||
|
||||
return filename
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
_SAFE_CHARS = {"-", "_"}
|
||||
|
||||
|
||||
@@ -16,9 +19,46 @@ def _sanitize_segment(value: str, fallback: str) -> str:
|
||||
return cleaned or fallback
|
||||
|
||||
|
||||
def find_repo_root() -> Path:
|
||||
"""Find the project repository root directory.
|
||||
|
||||
Resolution order:
|
||||
1. ALWRITY_ROOT_DIR environment variable (explicit override for production)
|
||||
2. Deterministic path from this file (storage_paths.py is at utils/)
|
||||
3. Walk-up fallback looking for a 'backend/' directory at project root
|
||||
|
||||
Returns an absolute, resolved Path.
|
||||
"""
|
||||
env_root = os.environ.get("ALWRITY_ROOT_DIR")
|
||||
if env_root:
|
||||
root = Path(env_root).resolve()
|
||||
if root.is_dir():
|
||||
return root
|
||||
|
||||
# storage_paths.py is at backend/utils/storage_paths.py
|
||||
# project root is parents[2] (utils -> backend -> root)
|
||||
this_file = Path(__file__).resolve()
|
||||
candidate = this_file.parents[2]
|
||||
|
||||
if (candidate / "backend").is_dir():
|
||||
return candidate
|
||||
|
||||
# Walk-up fallback for unusual deployments
|
||||
current = this_file.parent
|
||||
for _ in range(10):
|
||||
if (current / "backend").is_dir():
|
||||
return current
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
|
||||
return this_file.parents[2]
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
"""Return repository root as an absolute canonical path."""
|
||||
return Path(__file__).resolve().parents[2]
|
||||
return find_repo_root()
|
||||
|
||||
|
||||
def get_workspace_root() -> Path:
|
||||
@@ -67,3 +107,7 @@ def get_legacy_video_studio_upload_dirs() -> list[Path]:
|
||||
(repo_root / "backend" / "data" / "video_studio" / "uploads").resolve(),
|
||||
(repo_root / "backend" / "backend" / "data" / "video_studio" / "uploads").resolve(),
|
||||
]
|
||||
|
||||
|
||||
# Log resolved root at import time for production debugging
|
||||
logger.info(f"[StoragePaths] Repository root resolved to: {get_repo_root()}")
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface VoiceCloneResponse {
|
||||
voice_name?: string;
|
||||
preview_audio_url?: string;
|
||||
asset_id?: number;
|
||||
engine?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -194,6 +195,8 @@ export const createVoiceClone = async (
|
||||
params: VoiceCloneParams
|
||||
): Promise<VoiceCloneResponse> => {
|
||||
try {
|
||||
console.log('[VoiceClone] Creating voice clone with engine:', params.engine);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', params.audioFile);
|
||||
formData.append('engine', params.engine);
|
||||
@@ -213,14 +216,16 @@ export const createVoiceClone = async (
|
||||
// We might want to remove this if backend doesn't need it
|
||||
formData.append('voice_name', 'My Voice Clone');
|
||||
|
||||
const response = await apiClient.post('/onboarding/assets/create-voice-clone', formData, {
|
||||
console.log('[VoiceClone] Sending request to /onboarding/assets/create-voice-clone');
|
||||
const response = await aiApiClient.post('/onboarding/assets/create-voice-clone', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log('[VoiceClone] Response received:', response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Voice cloning error:', error);
|
||||
console.error('[VoiceClone] Error creating voice clone:', error.response?.data || error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || 'Failed to create voice clone'
|
||||
@@ -238,7 +243,7 @@ export const createVoiceDesign = async (
|
||||
params: VoiceDesignParams
|
||||
): Promise<VoiceCloneResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/onboarding/assets/create-voice-design', {
|
||||
const response = await aiApiClient.post('/onboarding/assets/create-voice-design', {
|
||||
text: params.text,
|
||||
voice_description: params.voiceDescription,
|
||||
language: params.language || 'auto',
|
||||
|
||||
@@ -224,8 +224,10 @@ apiClient.interceptors.request.use(
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const safeUrlWithToken = sanitizeUrlForLogging(config.url);
|
||||
console.log(`[apiClient] ✅ Auth token attached for request to ${safeUrlWithToken}`);
|
||||
}
|
||||
} else {
|
||||
// Token getter returned null - reject request to prevent 401 errors
|
||||
// ProtectedRoute should ensure user is authenticated before components render
|
||||
@@ -461,9 +463,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'}`)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,12 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
|
||||
};
|
||||
|
||||
const fetchDetailedStats = async () => {
|
||||
// Skip detailed stats in podcast-only mode (endpoint not available)
|
||||
if (isPodcastOnlyDemoMode()) {
|
||||
setChartData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get('/api/content-planning/monitoring/api-stats');
|
||||
const result = response?.data;
|
||||
@@ -176,8 +182,10 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
// Prime cache performance occasionally even when dashboard is closed
|
||||
// Skip detailed stats in podcast-only mode
|
||||
if (!isPodcastOnlyDemoMode()) {
|
||||
fetchDetailedStats();
|
||||
}
|
||||
|
||||
// Refresh every 120 seconds
|
||||
const interval = setInterval(fetchStatus, 120000);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { createAvatarVideoAsync } from '../../../../api/videoStudioApi';
|
||||
import { useVideoGenerationPolling } from '../../../../hooks/usePolling';
|
||||
import { fetchMediaBlobUrl } from '../../../../utils/fetchMediaBlobUrl';
|
||||
import { getAuthTokenGetter, getApiUrl } from '../../../../api/client';
|
||||
import { VideoCameraFront, SkipNext, PlayArrow, InfoOutlined, Close as CloseIcon, HelpOutline, Refresh, RestartAlt, Undo } from '@mui/icons-material';
|
||||
import { VideoGenerationLoader } from '../../../shared/VideoGenerationLoader';
|
||||
import { OperationButton } from '../../../shared/OperationButton';
|
||||
@@ -31,9 +32,35 @@ export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
|
||||
const [model, setModel] = useState<'infinitetalk' | 'hunyuan-avatar'>('infinitetalk');
|
||||
const [showCapabilities, setShowCapabilities] = useState(false);
|
||||
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
|
||||
const [authenticatedVoiceUrl, setAuthenticatedVoiceUrl] = useState<string | null>(null);
|
||||
const STORAGE_KEY = 'test_persona_video_url';
|
||||
const STORAGE_BACKUP_KEY = 'test_persona_video_url_backup';
|
||||
|
||||
// Append auth token to /api/ asset URLs so <audio> can access them
|
||||
useEffect(() => {
|
||||
if (!voiceUrl || !voiceUrl.includes('/api/')) {
|
||||
setAuthenticatedVoiceUrl(voiceUrl || null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const tokenGetter = getAuthTokenGetter();
|
||||
if (tokenGetter) {
|
||||
const token = await tokenGetter();
|
||||
if (token && !cancelled) {
|
||||
const absoluteUrl = voiceUrl.startsWith('/') ? `${getApiUrl()}${voiceUrl}` : voiceUrl;
|
||||
const sep = absoluteUrl.includes('?') ? '&' : '?';
|
||||
setAuthenticatedVoiceUrl(`${absoluteUrl}${sep}token=${encodeURIComponent(token)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
if (!cancelled) setAuthenticatedVoiceUrl(voiceUrl);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [voiceUrl]);
|
||||
|
||||
const [generatedVideoUrl, setGeneratedVideoUrl] = useState<string | null>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -527,7 +554,7 @@ export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
|
||||
<Typography variant="caption" fontWeight="bold" sx={{ mb: 1, display: 'block', color: '#64748b' }}>
|
||||
Voice Preview
|
||||
</Typography>
|
||||
<audio controls src={voiceUrl} style={{ width: '100%', height: 36 }} />
|
||||
<audio controls src={authenticatedVoiceUrl || undefined} style={{ width: '100%', height: 36 }} />
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Typography, Paper, Stack, Button, Alert, TextField, CircularProgress, Slider, FormControlLabel, Checkbox, MenuItem, Tooltip, Chip, Divider, Grid, IconButton, Modal, Fade, Backdrop, LinearProgress } from '@mui/material';
|
||||
import { keyframes } from '@mui/system';
|
||||
import { Mic, GraphicEq, Timer, CloudUpload, Stop, PlayArrow, InfoOutlined, TextFields, HelpOutline, AutoAwesome, Campaign, MicNone, Podcasts, RestartAlt, Undo, Headphones, Article, VideoLibrary, TrendingUp, CheckCircle, RecordVoiceOver } from '@mui/icons-material';
|
||||
import { Mic, GraphicEq, Timer, CloudUpload, Stop, PlayArrow, InfoOutlined, TextFields, HelpOutline, AutoAwesome, Campaign, MicNone, Podcasts, RestartAlt, Undo, Headphones, Article, VideoLibrary, TrendingUp, CheckCircle, RecordVoiceOver, Settings } from '@mui/icons-material';
|
||||
import { createVoiceClone, createVoiceDesign, getLatestVoiceClone, setBrandVoice } from '../../../../api/brandAssets';
|
||||
import { setCachedVoiceCloneInfo } from '../../../../services/podcastApi';
|
||||
import { getAuthTokenGetter, getApiUrl } from '../../../../api/client';
|
||||
import { OperationButton } from '../../../shared/OperationButton';
|
||||
|
||||
const pulse = keyframes`
|
||||
@@ -58,6 +60,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
||||
const [accuracy, setAccuracy] = useState(0.7);
|
||||
const [languageBoost, setLanguageBoost] = useState('auto');
|
||||
const [qualityPreset, setQualityPreset] = useState<'clean' | 'noisy' | 'accent'>('clean');
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
const [qwenLanguage, setQwenLanguage] = useState('auto');
|
||||
const [referenceText, setReferenceText] = useState('');
|
||||
const [voiceDescription, setVoiceDescription] = useState('');
|
||||
@@ -94,6 +97,43 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
||||
} catch { return null; }
|
||||
});
|
||||
|
||||
// Append auth token to /api/ asset URLs so <audio> elements can access them
|
||||
const [authenticatedAudioUrl, setAuthenticatedAudioUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[VoiceClone] resultAudioUrl changed:', resultAudioUrl);
|
||||
if (!resultAudioUrl || !resultAudioUrl.includes('/api/')) {
|
||||
console.log('[VoiceClone] Using resultAudioUrl directly (no API auth needed)');
|
||||
setAuthenticatedAudioUrl(resultAudioUrl);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const tokenGetter = getAuthTokenGetter();
|
||||
if (tokenGetter) {
|
||||
const token = await tokenGetter();
|
||||
console.log('[VoiceClone] Got token:', token ? 'yes' : 'no');
|
||||
if (token && !cancelled) {
|
||||
const absoluteUrl = resultAudioUrl.startsWith('/') ? `${getApiUrl()}${resultAudioUrl}` : resultAudioUrl;
|
||||
const sep = absoluteUrl.includes('?') ? '&' : '?';
|
||||
const authUrl = `${absoluteUrl}${sep}token=${encodeURIComponent(token)}`;
|
||||
console.log('[VoiceClone] Setting authenticatedAudioUrl:', authUrl);
|
||||
setAuthenticatedAudioUrl(authUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[VoiceClone] Token fetch error:', e);
|
||||
}
|
||||
if (!cancelled) {
|
||||
console.log('[VoiceClone] Falling back to unauthenticated URL');
|
||||
setAuthenticatedAudioUrl(resultAudioUrl);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [resultAudioUrl]);
|
||||
|
||||
const [archivedResultAudioUrl, setArchivedResultAudioUrl] = useState<string | null>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_BACKUP_KEY);
|
||||
@@ -253,6 +293,13 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
||||
} catch (e) {
|
||||
console.warn('Failed to save voice selection to storage', e);
|
||||
}
|
||||
// Also persist to cross-phase cache for Write phase
|
||||
setCachedVoiceCloneInfo({
|
||||
customVoiceId: customVoiceId || undefined,
|
||||
voiceSampleUrl: resultAudioUrl || undefined,
|
||||
engine: engine || 'qwen3',
|
||||
isVoiceClone: true,
|
||||
});
|
||||
if (onVoiceSet) onVoiceSet();
|
||||
} else {
|
||||
setError(resp.error || 'Failed to set brand voice');
|
||||
@@ -382,7 +429,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
||||
}
|
||||
setAudioFile(file);
|
||||
const url = URL.createObjectURL(blob);
|
||||
console.log('[VoiceClone] Created audio preview URL:', url, 'size:', file.size, 'type:', blob.type);
|
||||
if (process.env.NODE_ENV === 'development') console.log('[VoiceClone] Created audio preview URL:', url.split('?')[0], 'size:', file.size, 'type:', blob.type);
|
||||
setAudioPreviewUrl(url);
|
||||
} catch (err) {
|
||||
console.error('[VoiceClone] Error creating audio blob:', err);
|
||||
@@ -471,6 +518,13 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
||||
if (resp.success) {
|
||||
setSuccess(resp.message || 'Voice generated successfully');
|
||||
setResultAudioUrl(resp.preview_audio_url || null);
|
||||
// Persist to cross-phase cache so Write phase can use it immediately
|
||||
setCachedVoiceCloneInfo({
|
||||
customVoiceId: resp.custom_voice_id || undefined,
|
||||
voiceSampleUrl: resp.preview_audio_url || undefined,
|
||||
engine: resp.engine || 'qwen3',
|
||||
isVoiceClone: true,
|
||||
});
|
||||
} else {
|
||||
setError(resp.error || 'Voice generation failed');
|
||||
}
|
||||
@@ -518,6 +572,13 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
||||
if (resp.success) {
|
||||
setSuccess('Voice generated successfully. Use this for generating your Brand Voice.');
|
||||
setResultAudioUrl(resp.preview_audio_url || null);
|
||||
// Persist to cross-phase cache so Write phase can use it immediately
|
||||
setCachedVoiceCloneInfo({
|
||||
customVoiceId: resp.custom_voice_id || customVoiceId || undefined,
|
||||
voiceSampleUrl: resp.preview_audio_url || undefined,
|
||||
engine: resp.engine || engine || 'qwen3',
|
||||
isVoiceClone: true,
|
||||
});
|
||||
} else {
|
||||
setError(resp.error || 'Voice clone failed');
|
||||
}
|
||||
@@ -813,6 +874,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
||||
key={audioPreviewUrl}
|
||||
controls
|
||||
src={audioPreviewUrl}
|
||||
preload="auto"
|
||||
style={{ height: '30px', width: '100%' }}
|
||||
onError={(e) => {
|
||||
console.error('[VoiceClone] Audio playback error:', e);
|
||||
@@ -826,20 +888,42 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4B5563', mb: 1, display: 'block', fontSize: '0.7rem' }}>
|
||||
Read one of these scripts to capture your voice:
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1F2937', display: 'block', fontSize: '0.75rem', letterSpacing: '0.5px', textTransform: 'uppercase' }}>
|
||||
Read this script to capture your voice:
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||
sx={{
|
||||
color: showAdvancedOptions ? '#7C3AED' : '#9CA3AF',
|
||||
bgcolor: showAdvancedOptions ? 'rgba(124, 58, 237, 0.1)' : 'transparent',
|
||||
'&:hover': { bgcolor: 'rgba(124, 58, 237, 0.15)' }
|
||||
}}
|
||||
>
|
||||
<Settings fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
bgcolor: '#FFFFFF',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
border: '2px solid transparent',
|
||||
background: 'linear-gradient(#FFFFFF, #FFFFFF) padding-box, linear-gradient(135deg, #7C3AED 0%, #EC4899 50%, #3B82F6 100%) border-box',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 20px rgba(124, 58, 237, 0.15)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem', color: '#374151', lineHeight: 1.7, fontStyle: 'italic' }}>
|
||||
"Hi, I'm excited to use AI to scale my content creation. This voice clone will help me stay consistent across all my channels. At our company, we value transparency and innovation, and we strive to deliver the best solutions for our clients every single day."
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{[
|
||||
"Hi, I'm excited to use AI to scale my content creation. This voice clone will help me stay consistent across all my channels.",
|
||||
"At our company, we value transparency and innovation. We strive to deliver the best solutions for our clients every single day.",
|
||||
"Imagine a world where creativity knows no bounds. Where your ideas can take flight and reach millions of people instantly."
|
||||
].map((text, i) => (
|
||||
<Paper key={i} elevation={0} sx={{ p: 1, bgcolor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '8px', cursor: 'pointer', transition: 'all 0.2s', '&:hover': { borderColor: '#7C3AED', bgcolor: '#F9FAFB', transform: 'translateY(-1px)' } }}>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.75rem', color: '#374151', lineHeight: 1.4, fontStyle: 'italic' }}>"{text}"</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -899,7 +983,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
||||
{(audioPreviewUrl || audioFile || (inputType === 'text' && voiceDescription?.trim().length > 0)) && <Divider sx={{ borderColor: '#F3F4F6' }} />}
|
||||
|
||||
{/* Inputs for Voice Cloning (Mic/Upload) - Shown only after sample available */}
|
||||
{inputType !== 'text' && (audioPreviewUrl || audioFile) && (
|
||||
{inputType !== 'text' && (audioPreviewUrl || audioFile) && showAdvancedOptions && (
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
@@ -976,7 +1060,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
||||
)}
|
||||
|
||||
{/* Inputs for Voice Design (Text) - Shown only after description provided */}
|
||||
{inputType === 'text' && voiceDescription?.trim().length > 0 && (
|
||||
{inputType === 'text' && voiceDescription?.trim().length > 0 && showAdvancedOptions && (
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
@@ -1053,6 +1137,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
||||
key={audioPreviewUrl}
|
||||
controls
|
||||
src={audioPreviewUrl}
|
||||
preload="auto"
|
||||
style={{ width: '100%', height: '28px' }}
|
||||
onError={(e) => {
|
||||
console.error('[VoiceClone] Source audio playback error:', e);
|
||||
@@ -1065,7 +1150,21 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
||||
<Typography variant="caption" fontWeight="800" sx={{ color: '#EC4899', textTransform: 'uppercase', mb: 0.25, display: 'block', fontSize: '0.65rem' }}>
|
||||
Generated AI Voice Preview
|
||||
</Typography>
|
||||
<audio controls src={resultAudioUrl} style={{ width: '100%', height: '28px' }} />
|
||||
<audio
|
||||
key={authenticatedAudioUrl}
|
||||
controls
|
||||
src={authenticatedAudioUrl || undefined}
|
||||
preload="auto"
|
||||
style={{ width: '100%', height: '28px' }}
|
||||
onLoadedMetadata={(e) => {
|
||||
console.log('[VoiceClone] Audio duration loaded:', (e.target as HTMLAudioElement).duration);
|
||||
}}
|
||||
onError={(e) => {
|
||||
const audioEl = e.target as HTMLAudioElement;
|
||||
const errorMsg = audioEl?.error ? `code=${audioEl.error.code}, message=${audioEl.error.message}` : 'unknown';
|
||||
console.error('[VoiceClone] Generated audio playback error:', errorMsg, 'URL:', authenticatedAudioUrl);
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Stack, Paper, Box, Chip, Typography } from "@mui/material";
|
||||
import { Stack, Paper, Box, Chip, Typography, Dialog, DialogTitle, DialogContent, DialogActions, CircularProgress } from "@mui/material";
|
||||
import { AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
import { CreateProjectPayload, Knobs, PodcastMode } from "./types";
|
||||
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";
|
||||
@@ -13,6 +16,8 @@ import { PodcastConfiguration } from "./CreateStep/PodcastConfiguration";
|
||||
import { AvatarSelector } from "./CreateStep/AvatarSelector";
|
||||
import { CreateActions } from "./CreateStep/CreateActions";
|
||||
import { EnhancedTopicChoicesModal } from "./EnhancedTopicChoicesModal";
|
||||
import { TrendingTopicsModal } from "./CreateStep/TrendingTopicsModal";
|
||||
import { CategoryResearchModal } from "./CreateStep/CategoryResearchModal";
|
||||
|
||||
const ENHANCE_TOPIC_PROGRESS_MESSAGES = [
|
||||
"Analyzing your topic idea...",
|
||||
@@ -20,6 +25,53 @@ const ENHANCE_TOPIC_PROGRESS_MESSAGES = [
|
||||
"Aligning language for podcast listeners...",
|
||||
];
|
||||
|
||||
// Dynamic progress messages based on context
|
||||
const getEnhanceProgressMessage = (index: number, hasWebsite: boolean, hasTopicContext: boolean): string => {
|
||||
const messagesWithAll = [
|
||||
"Analyzing your topic with website and category research...",
|
||||
"Incorporating website insights and research findings...",
|
||||
"Generating podcast angles based on all available context...",
|
||||
"Creating personalized episode concepts...",
|
||||
"Finalizing enhanced pitch options...",
|
||||
];
|
||||
|
||||
const messagesWithWebsite = [
|
||||
"Analyzing your topic with website content...",
|
||||
"Incorporating website insights and company details...",
|
||||
"Generating podcast angles based on your website analysis...",
|
||||
"Creating personalized episode concepts...",
|
||||
"Finalizing enhanced pitch options...",
|
||||
];
|
||||
|
||||
const messagesWithTopic = [
|
||||
"Analyzing your topic with category research...",
|
||||
"Incorporating research insights and trends...",
|
||||
"Generating podcast angles based on your research...",
|
||||
"Creating personalized episode concepts...",
|
||||
"Finalizing enhanced pitch options...",
|
||||
];
|
||||
|
||||
const messagesBasic = [
|
||||
"Analyzing your topic idea...",
|
||||
"Enhancing clarity and hook...",
|
||||
"Aligning language for podcast listeners...",
|
||||
"Crafting compelling angles...",
|
||||
"Finalizing recommendations...",
|
||||
];
|
||||
|
||||
let messages;
|
||||
if (hasWebsite && hasTopicContext) {
|
||||
messages = messagesWithAll;
|
||||
} else if (hasWebsite) {
|
||||
messages = messagesWithWebsite;
|
||||
} else if (hasTopicContext) {
|
||||
messages = messagesWithTopic;
|
||||
} else {
|
||||
messages = messagesBasic;
|
||||
}
|
||||
return messages[index % messages.length];
|
||||
};
|
||||
|
||||
interface CreateModalProps {
|
||||
onCreate: (payload: CreateProjectPayload) => void;
|
||||
open: boolean;
|
||||
@@ -59,6 +111,46 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
const [choicesModalOpen, setChoicesModalOpen] = useState(false);
|
||||
const [editedChoices, setEditedChoices] = useState<string[]>([]);
|
||||
|
||||
// Website extraction data for AI enhance
|
||||
const [websiteData, setWebsiteData] = useState<{
|
||||
title?: string;
|
||||
text?: string;
|
||||
summary?: string;
|
||||
highlights?: string[];
|
||||
url: string;
|
||||
subpages?: Array<{id?: string; title?: string; url?: string; summary?: string; text?: string}>;
|
||||
} | null>(null);
|
||||
|
||||
// Category research context for AI enhance
|
||||
const [topicContext, setTopicContext] = useState<{
|
||||
category: string;
|
||||
topics: Array<{title: string; url: string; snippet: string; score: number}>;
|
||||
selected_topic: {title: string; url: string; snippet: string};
|
||||
} | null>(null);
|
||||
|
||||
// Enhance topic progress modal state
|
||||
const [showEnhanceProgressModal, setShowEnhanceProgressModal] = useState(false);
|
||||
|
||||
// Trending topics state
|
||||
const [trendingModalOpen, setTrendingModalOpen] = useState(false);
|
||||
const [trendingLoading, setTrendingLoading] = useState(false);
|
||||
|
||||
// Category research state
|
||||
const [categoryResearchOpen, setCategoryResearchOpen] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<"news" | "finance" | "research-paper" | "personal-site">("news");
|
||||
const [categoryLoading, setCategoryLoading] = useState(false);
|
||||
const [categoryTopics, setCategoryTopics] = useState<Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
favicon?: string;
|
||||
}>>([]);
|
||||
const [categoryError, setCategoryError] = useState<string | null>(null);
|
||||
const [categoryCached, setCategoryCached] = useState(false);
|
||||
const [lastSearchedTopic, setLastSearchedTopic] = useState<string>("");
|
||||
const [lastSearchedCategory, setLastSearchedCategory] = useState<"news" | "finance" | "research-paper" | "personal-site">("news");
|
||||
|
||||
// Rotate placeholder every 3 seconds
|
||||
useEffect(() => {
|
||||
if (!topicInput) {
|
||||
@@ -69,6 +161,46 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
}
|
||||
}, [topicInput]);
|
||||
|
||||
// Cost estimate state - compatible with TopicUrlInput props
|
||||
type EstimateType = number | { ttsCost: number; avatarCost: number; videoCost: number; researchCost: number; total: number; } | null;
|
||||
const [estimatedCost, setEstimatedCost] = useState<EstimateType>(null);
|
||||
const [costEstimateLoading, setCostEstimateLoading] = useState(false);
|
||||
|
||||
// Fetch cost estimate when config changes
|
||||
useEffect(() => {
|
||||
const fetchEstimate = async () => {
|
||||
if (!duration || !speakers || !podcastMode) return;
|
||||
|
||||
setCostEstimateLoading(true);
|
||||
try {
|
||||
const result = await podcastApi.preEstimateCost({
|
||||
duration,
|
||||
speakers,
|
||||
queryCount: 3, // Default to 3 queries
|
||||
podcastMode,
|
||||
});
|
||||
|
||||
console.log('[Cost Estimate] Response:', result);
|
||||
console.log('[Cost Estimate] Total:', result.estimate?.total);
|
||||
console.log('[Cost Estimate] Full breakdown:', result.estimate);
|
||||
|
||||
if (result.estimate?.total !== undefined) {
|
||||
// Store full estimate object for tooltip
|
||||
setEstimatedCost(result.estimate);
|
||||
} else {
|
||||
setEstimatedCost(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Cost estimate error:", error);
|
||||
setEstimatedCost(null);
|
||||
} finally {
|
||||
setCostEstimateLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEstimate();
|
||||
}, [duration, speakers, podcastMode]);
|
||||
|
||||
// Fetch Brand Avatar on mount but don't select it
|
||||
useEffect(() => {
|
||||
const fetchBrandAvatar = async () => {
|
||||
@@ -87,6 +219,28 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
fetchBrandAvatar();
|
||||
}, []);
|
||||
|
||||
// Load saved website extraction on mount
|
||||
useEffect(() => {
|
||||
const loadSavedWebsiteExtraction = async () => {
|
||||
try {
|
||||
const result = await podcastApi.getWebsiteExtraction();
|
||||
if (result.success && result.data) {
|
||||
setWebsiteData({
|
||||
title: result.data.title,
|
||||
text: result.data.text,
|
||||
summary: result.data.summary,
|
||||
highlights: result.data.highlights,
|
||||
url: result.data.url,
|
||||
subpages: result.data.subpages,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load saved website extraction:", error);
|
||||
}
|
||||
};
|
||||
loadSavedWebsiteExtraction();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!avatarPreview) {
|
||||
setAvatarPreviewBlobUrl(null);
|
||||
@@ -197,7 +351,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
};
|
||||
|
||||
const isUrl = useMemo(() => detectUrl(topicInput), [topicInput]);
|
||||
const enhanceTopicMessage = enhancingTopic ? ENHANCE_TOPIC_PROGRESS_MESSAGES[enhanceTopicProgressIndex] : undefined;
|
||||
const enhanceTopicMessage = enhancingTopic ? getEnhanceProgressMessage(enhanceTopicProgressIndex, !!websiteData, !!topicContext) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enhancingTopic) {
|
||||
@@ -206,22 +360,39 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setEnhanceTopicProgressIndex((prev) => (prev + 1) % ENHANCE_TOPIC_PROGRESS_MESSAGES.length);
|
||||
setEnhanceTopicProgressIndex((prev) => {
|
||||
const maxMessages = (websiteData || topicContext) ? 5 : 3;
|
||||
return (prev + 1) % maxMessages;
|
||||
});
|
||||
}, 1200);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [enhancingTopic]);
|
||||
}, [enhancingTopic, websiteData, topicContext]);
|
||||
|
||||
// Handle AI Details button click
|
||||
const handleAIDetailsClick = async () => {
|
||||
if (!topicInput.trim() || enhancingTopic) return;
|
||||
|
||||
// Show progress modal
|
||||
setShowEnhanceProgressModal(true);
|
||||
|
||||
try {
|
||||
setEnhancingTopic(true);
|
||||
// We pass the current Bible context if we have it (unlikely here as it's generated in analysis)
|
||||
// But the backend will generate it from onboarding data if missing
|
||||
|
||||
// Build website data (excluding images/favicon)
|
||||
const websiteDataForApi = websiteData ? {
|
||||
title: websiteData.title,
|
||||
text: websiteData.text,
|
||||
summary: websiteData.summary,
|
||||
highlights: websiteData.highlights,
|
||||
url: websiteData.url,
|
||||
subpages: websiteData.subpages,
|
||||
} : undefined;
|
||||
|
||||
const result = await podcastApi.enhanceIdea({
|
||||
idea: topicInput,
|
||||
website_data: websiteDataForApi,
|
||||
topic_context: topicContext || undefined,
|
||||
});
|
||||
|
||||
if (result.enhanced_ideas && result.enhanced_ideas.length === 3) {
|
||||
@@ -234,9 +405,67 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
console.error("Failed to enhance idea with AI:", error);
|
||||
} finally {
|
||||
setEnhancingTopic(false);
|
||||
setShowEnhanceProgressModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Category Research (News/Finance/Research Papers/Personal Website) click
|
||||
const handleCategoryResearchClick = async (category: "news" | "finance" | "research-paper" | "personal-site", websiteUrl?: string, forceRefresh: boolean = false, overrideKeyword?: string) => {
|
||||
const currentTopic = (overrideKeyword || topicInput.trim());
|
||||
|
||||
// Check if we have cached results for the same topic + category combination (only if not force refresh)
|
||||
if (!forceRefresh && !overrideKeyword && currentTopic === lastSearchedTopic && category === lastSearchedCategory && categoryTopics.length > 0) {
|
||||
setSelectedCategory(category);
|
||||
setCategoryResearchOpen(true);
|
||||
setCategoryCached(true);
|
||||
setCategoryLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCategory(category);
|
||||
setCategoryResearchOpen(true);
|
||||
setCategoryLoading(true);
|
||||
setCategoryError(null);
|
||||
setCategoryCached(false);
|
||||
setCategoryTopics([]);
|
||||
|
||||
// For personal-site, check if topic input looks like a URL
|
||||
let websiteUrlToUse: string | undefined;
|
||||
if (category === "personal-site" && topicInput.trim()) {
|
||||
const topicText = topicInput.trim();
|
||||
// Check if it looks like a URL
|
||||
if (topicText.startsWith('http://') || topicText.startsWith('https://') || topicText.includes('://') || (topicText.includes('.') && !topicText.includes(' '))) {
|
||||
websiteUrlToUse = topicText;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await podcastApi.researchByCategory({
|
||||
category,
|
||||
keyword: currentTopic || undefined,
|
||||
maxResults: 8,
|
||||
websiteUrl: websiteUrlToUse,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setCategoryTopics(result.topics || []);
|
||||
setLastSearchedTopic(currentTopic);
|
||||
setLastSearchedCategory(category);
|
||||
} else {
|
||||
setCategoryError(result.error || `Failed to fetch ${category} topics`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setCategoryError(error?.message || `Failed to fetch ${category} topics`);
|
||||
} finally {
|
||||
setCategoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Redo Search for category research
|
||||
const handleCategoryRedoSearch = (keyword: string, websiteUrl?: string) => {
|
||||
handleCategoryResearchClick(selectedCategory, websiteUrl, true, keyword);
|
||||
};
|
||||
|
||||
// Handle enhanced topic choice selection
|
||||
const handleChoiceSelection = (selectedIndex: number, editedChoice: string) => {
|
||||
const selectedTopic = editedChoice;
|
||||
@@ -253,27 +482,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
setShowAIDetailsButton(topicInput.trim().length > 0 && !isUrl);
|
||||
}, [topicInput, isUrl]);
|
||||
|
||||
// Calculate estimated cost
|
||||
const estimatedCost = useMemo(() => {
|
||||
const chars = Math.max(1000, duration * 900); // ~900 chars per minute
|
||||
const secs = duration * 60;
|
||||
|
||||
const ttsCost = (chars / 1000) * 0.05;
|
||||
const avatarCost = speakers * 0.15;
|
||||
const videoRate = knobs.bitrate === 'hd' ? 0.06 : 0.03;
|
||||
const videoCost = secs * videoRate;
|
||||
const researchCost = 0.3; // Fixed research cost
|
||||
|
||||
return {
|
||||
ttsCost: +ttsCost.toFixed(2),
|
||||
avatarCost: +avatarCost.toFixed(2),
|
||||
videoCost: +videoCost.toFixed(2),
|
||||
researchCost: +researchCost.toFixed(2),
|
||||
total: +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2),
|
||||
};
|
||||
}, [duration, speakers, knobs.bitrate, knobs.scene_length_target]);
|
||||
|
||||
// Check if avatar is present (from any source: upload, brand avatar, or generated)
|
||||
// Check if avatar is present (from any source: upload, selfie, brand avatar, or asset library)
|
||||
const hasAvatar = Boolean(
|
||||
avatarFile || // User uploaded an image
|
||||
avatarUrl || // Already processed avatar URL
|
||||
@@ -287,8 +496,11 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
const hasVoice = Boolean(selectedVoiceId);
|
||||
const hasDuration = Boolean(duration > 0 && duration <= 10);
|
||||
const hasSpeakers = Boolean(speakers >= 1 && speakers <= 2);
|
||||
const hasPodcastMode = Boolean(podcastMode);
|
||||
|
||||
const canSubmit = Boolean(hasTopic && (podcastMode === "audio_only" || hasAvatar) && hasVoice && hasDuration && hasSpeakers);
|
||||
// Required: topic, duration, speakers, voice, podcastMode, presenter avatar
|
||||
// Avatar required for video modes; for audio_only, still require avatar for presenter display
|
||||
const canSubmit = Boolean(hasTopic && hasVoice && hasDuration && hasSpeakers && hasPodcastMode && hasAvatar);
|
||||
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
@@ -300,20 +512,39 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
// Determine if input is idea or URL
|
||||
// For URL, we extract the first URL found or use the whole string if it's a direct URL
|
||||
let finalIdea = "";
|
||||
let finalUrl = "";
|
||||
|
||||
if (isUrl) {
|
||||
// Simple extraction: if the input contains a URL, we treat the input as the URL (or extract it)
|
||||
// For now, let's assume the user pasted a URL.
|
||||
// If there's mixed text, we might want to just send the whole thing as 'url' if the backend handles extraction,
|
||||
// or extract it here.
|
||||
// The previous logic used specific 'url' state.
|
||||
// Extract the URL from the input
|
||||
const urlMatch = topicInput.match(/(https?:\/\/[^\s]+)/);
|
||||
if (urlMatch) {
|
||||
finalUrl = urlMatch[0];
|
||||
const detectedUrl = urlMatch ? urlMatch[0] : topicInput;
|
||||
|
||||
// Extract content from the URL using Exa
|
||||
try {
|
||||
setEnhancingTopic(true);
|
||||
setEnhanceTopicProgressIndex(0);
|
||||
|
||||
const { podcastApi } = await import("../../services/podcastApi");
|
||||
const extractResult = await podcastApi.extractUrl({ url: detectedUrl });
|
||||
|
||||
if (extractResult.success && extractResult.summary) {
|
||||
// Use extracted content as the podcast topic
|
||||
finalIdea = extractResult.summary;
|
||||
if (extractResult.title) {
|
||||
finalIdea = `${extractResult.title}: ${finalIdea}`;
|
||||
}
|
||||
} else if (extractResult.success && extractResult.text) {
|
||||
// Fallback to text if no summary
|
||||
finalIdea = extractResult.text.substring(0, 500);
|
||||
} else {
|
||||
// Fallback
|
||||
finalUrl = topicInput;
|
||||
// Fallback: use the URL itself if extraction fails
|
||||
finalIdea = detectedUrl;
|
||||
console.warn("[CreateModal] URL extraction failed:", extractResult.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[CreateModal] URL extraction error:", error);
|
||||
finalIdea = detectedUrl; // Fallback to URL
|
||||
} finally {
|
||||
setEnhancingTopic(false);
|
||||
}
|
||||
} else {
|
||||
finalIdea = topicInput;
|
||||
@@ -333,14 +564,54 @@ 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
|
||||
// VoiceSelector may pass VOICE_CLONE_ID, the actual clone ID (vc_*), or a system voice ID
|
||||
const selectedLooksLikeClone = selectedVoiceId?.startsWith("vc_") || selectedVoiceId === "MY_VOICE_CLONE";
|
||||
const isVoiceClone = selectedVoiceId === VOICE_CLONE_ID || selectedLooksLikeClone || knobs.custom_voice_id === selectedVoiceId;
|
||||
|
||||
let voiceSampleUrl: string | undefined;
|
||||
let voiceCloneEngine: string | undefined;
|
||||
let customVoiceId: string | undefined;
|
||||
|
||||
if (isVoiceClone) {
|
||||
// If VoiceSelector already gave us the real clone ID, use it as fallback
|
||||
if (selectedLooksLikeClone && selectedVoiceId !== VOICE_CLONE_ID) {
|
||||
customVoiceId = selectedVoiceId;
|
||||
}
|
||||
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 {
|
||||
await onCreate({
|
||||
ideaOrUrl: finalUrl || finalIdea,
|
||||
ideaOrUrl: finalIdea,
|
||||
speakers,
|
||||
duration,
|
||||
knobs: finalKnobs,
|
||||
@@ -557,12 +828,19 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
isUrl={isUrl}
|
||||
showAIDetailsButton={showAIDetailsButton}
|
||||
onAIDetailsClick={handleAIDetailsClick}
|
||||
onTrendingTopicsClick={() => setTrendingModalOpen(true)}
|
||||
onCategoryResearchClick={handleCategoryResearchClick}
|
||||
placeholderIndex={placeholderIndex}
|
||||
loading={enhancingTopic}
|
||||
loadingMessage={enhanceTopicMessage}
|
||||
extractedData={websiteData}
|
||||
setExtractedData={setWebsiteData}
|
||||
trendingLoading={trendingLoading}
|
||||
categoryResearchLoading={categoryLoading}
|
||||
estimatedCost={estimatedCost}
|
||||
duration={duration}
|
||||
speakers={speakers}
|
||||
podcastMode={podcastMode}
|
||||
knobs={knobs}
|
||||
/>
|
||||
</Box>
|
||||
@@ -626,6 +904,135 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
onSelectChoice={handleChoiceSelection}
|
||||
loading={enhancingTopic}
|
||||
/>
|
||||
|
||||
{/* Trending Topics Modal */}
|
||||
<TrendingTopicsModal
|
||||
open={trendingModalOpen}
|
||||
onClose={() => setTrendingModalOpen(false)}
|
||||
onSelectTopic={(topic) => setTopicInput(topic)}
|
||||
initialKeywords={topicInput}
|
||||
/>
|
||||
|
||||
{/* Category Research Modal */}
|
||||
<CategoryResearchModal
|
||||
open={categoryResearchOpen}
|
||||
onClose={() => setCategoryResearchOpen(false)}
|
||||
category={selectedCategory}
|
||||
keyword={topicInput}
|
||||
websiteUrl={selectedCategory === "personal-site" ? topicInput : undefined}
|
||||
loading={categoryLoading}
|
||||
topics={categoryTopics}
|
||||
error={categoryError}
|
||||
onSelectTopic={(topic) => {
|
||||
// Save topic context
|
||||
const selectedTopicData = categoryTopics.find(t => t.title === topic);
|
||||
if (selectedTopicData) {
|
||||
setTopicContext({
|
||||
category: selectedCategory,
|
||||
topics: categoryTopics.map(t => ({title: t.title, url: t.url, snippet: t.snippet, score: t.score})),
|
||||
selected_topic: {
|
||||
title: selectedTopicData.title,
|
||||
url: selectedTopicData.url,
|
||||
snippet: selectedTopicData.snippet,
|
||||
},
|
||||
});
|
||||
}
|
||||
setTopicInput(topic);
|
||||
setCategoryResearchOpen(false);
|
||||
}}
|
||||
onRedoSearch={handleCategoryRedoSearch}
|
||||
onConfirmSelection={(selectedTopics) => {
|
||||
if (selectedTopics.length > 0) {
|
||||
// Save topic context
|
||||
const firstSelected = categoryTopics.find(t => t.title === selectedTopics[0]);
|
||||
if (firstSelected) {
|
||||
setTopicContext({
|
||||
category: selectedCategory,
|
||||
topics: categoryTopics.map(t => ({title: t.title, url: t.url, snippet: t.snippet, score: t.score})),
|
||||
selected_topic: {
|
||||
title: firstSelected.title,
|
||||
url: firstSelected.url,
|
||||
snippet: firstSelected.snippet,
|
||||
},
|
||||
});
|
||||
}
|
||||
setTopicInput(selectedTopics[0]);
|
||||
}
|
||||
setCategoryResearchOpen(false);
|
||||
}}
|
||||
isCached={categoryCached}
|
||||
/>
|
||||
|
||||
{/* Enhance Topic Progress Modal */}
|
||||
<Dialog
|
||||
open={showEnhanceProgressModal}
|
||||
disableEscapeKeyDown={false}
|
||||
onClose={() => setShowEnhanceProgressModal(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: "linear-gradient(135deg, #1e1b4b 0%, #312e81 100%)",
|
||||
backgroundColor: "#1e1b4b",
|
||||
color: "#fff",
|
||||
borderRadius: 3,
|
||||
boxShadow: "0 8px 40px rgba(49, 46, 129, 0.4)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1.5, fontWeight: 600 }}>
|
||||
<CircularProgress size={20} sx={{ color: "#a78bfa" }} />
|
||||
Enhancing Your Topic
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ textAlign: "center", py: 4 }}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<CircularProgress
|
||||
size={60}
|
||||
thickness={4}
|
||||
sx={{
|
||||
color: "#a78bfa",
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1, color: "#fff" }}>
|
||||
{enhanceTopicMessage || "Processing your topic..."}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mb: 2 }}>
|
||||
This may take a few seconds
|
||||
</Typography>
|
||||
|
||||
{/* Context info */}
|
||||
<Box sx={{ mt: 3, p: 2, bgcolor: "rgba(255,255,255,0.1)", borderRadius: 2 }}>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", display: "block", mb: 1 }}>
|
||||
Using context from:
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap" useFlexGap>
|
||||
{websiteData && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={websiteData.title ? `${websiteData.title.slice(0, 15)}...` : "Website"}
|
||||
sx={{ bgcolor: "rgba(167, 139, 250, 0.3)", color: "#fff" }}
|
||||
/>
|
||||
)}
|
||||
{topicContext && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${topicContext.category.charAt(0).toUpperCase() + topicContext.category.slice(1)} Research`}
|
||||
sx={{ bgcolor: "rgba(16, 185, 129, 0.3)", color: "#fff" }}
|
||||
/>
|
||||
)}
|
||||
{(!websiteData && !topicContext) && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Topic only"
|
||||
sx={{ bgcolor: "rgba(100, 116, 139, 0.3)", color: "#fff" }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -102,80 +102,76 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
p: { xs: 1.5, sm: 2.5 },
|
||||
borderRadius: 2,
|
||||
borderRadius: 3,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(15, 23, 42, 0.08)",
|
||||
boxShadow: "0 1px 2px rgba(15, 23, 42, 0.04)",
|
||||
border: "1px solid",
|
||||
borderColor: "#e2e8f0",
|
||||
boxShadow: "0 8px 30px rgba(15, 23, 42, 0.12)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "3px",
|
||||
background: "linear-gradient(90deg, #667eea 0%, #764ba2 50%, #667eea 100%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1, sm: 1.5 }} alignItems={{ xs: "flex-start", sm: "center" }} sx={{ mb: 2 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
{/* Header with Tabs */}
|
||||
<Box sx={{ px: 2.5, pt: 2.5, pb: 1.5, background: "linear-gradient(180deg, #eff6ff 0%, #f0f9ff 60%, #ffffff 100%)", borderBottom: "1px solid #e0e7ff" }}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1.5, sm: 1.5 }} alignItems={{ xs: "flex-start", sm: "center" }} justifyContent="space-between">
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: "#fff", fontSize: "0.75rem", fontWeight: 700 }}>3</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 1.5,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
<PersonIcon fontSize="small" sx={{ color: "#667eea" }} />
|
||||
<PersonIcon fontSize="medium" sx={{ color: "#6366f1" }} />
|
||||
</Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700, fontSize: "1rem", letterSpacing: "-0.01em" }}>
|
||||
Podcast Presenter Avatar
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Avatar Options:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
|
||||
<strong>Brand Avatar:</strong> Use your configured brand avatar for consistency.<br/><br/>
|
||||
<strong>Asset Library:</strong> Choose from your previously uploaded images.<br/><br/>
|
||||
<strong>Take a Selfie:</strong> Use your camera to capture a photo instantly for your podcast presenter.<br/><br/>
|
||||
<strong>Upload your photo:</strong> We'll enhance it into a professional podcast presenter using AI.
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.75rem", display: "block", mt: -0.25 }}>
|
||||
Select or upload an image for your presenter
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<InfoIcon fontSize="small" sx={{ color: "#94a3b8", cursor: "help", ml: { xs: 0, sm: 0 } }} />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", lg: "row" }} spacing={3} alignItems="flex-start">
|
||||
{/* Left Side: Tabs & Content */}
|
||||
<Box sx={{ flex: 1, width: "100%" }}>
|
||||
{/* Tabs in header - Mobile Responsive */}
|
||||
<Tabs
|
||||
value={avatarTab}
|
||||
onChange={setAvatarTab}
|
||||
variant="scrollable"
|
||||
scrollButtons={isMobile ? "auto" : false}
|
||||
allowScrollButtonsMobile={isMobile}
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
sx={{
|
||||
mb: { xs: 2, sm: 3 },
|
||||
minHeight: { xs: 36, sm: 48 },
|
||||
minHeight: { xs: 32, sm: 38 },
|
||||
"& .MuiTabs-scrollButtons": {
|
||||
color: "#64748b",
|
||||
"&.Mui-disabled": { opacity: 0.3 },
|
||||
@@ -184,30 +180,30 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
||||
display: "none",
|
||||
},
|
||||
"& .MuiTabs-flexContainer": {
|
||||
gap: { xs: 0.5, sm: 1.5 },
|
||||
gap: 0.5,
|
||||
},
|
||||
"& .MuiTab-root": {
|
||||
textTransform: "none",
|
||||
minHeight: { xs: 32, sm: 44 },
|
||||
minHeight: { xs: 28, sm: 36 },
|
||||
fontWeight: 600,
|
||||
fontSize: { xs: "0.7rem", sm: "0.875rem" },
|
||||
borderRadius: { xs: "6px", sm: "12px" },
|
||||
px: { xs: 1, sm: 2.5 },
|
||||
minWidth: { xs: "auto", sm: 0 },
|
||||
fontSize: { xs: "0.65rem", sm: "0.8rem" },
|
||||
borderRadius: 1,
|
||||
px: { xs: 1, sm: 1.5 },
|
||||
py: 0.5,
|
||||
minWidth: "auto",
|
||||
color: "#64748b",
|
||||
border: "1.5px solid #e2e8f0",
|
||||
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
border: "1px solid #e2e8f0",
|
||||
backgroundColor: "#ffffff",
|
||||
transition: "all 0.2s ease",
|
||||
"&:hover": {
|
||||
borderColor: "#cbd5e1",
|
||||
backgroundColor: "#f8fafc",
|
||||
transform: { xs: "none", sm: "translateY(-1px)" },
|
||||
borderColor: "#c7d2fe",
|
||||
backgroundColor: "#eef2ff",
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
color: "#ffffff",
|
||||
borderColor: "transparent",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.25)",
|
||||
background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
|
||||
boxShadow: "0 2px 8px rgba(99, 102, 241, 0.3)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -217,6 +213,33 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: "#fff" }}>
|
||||
Avatar Options:
|
||||
</Typography>
|
||||
<Typography variant="caption" component="div" sx={{ lineHeight: 1.6, color: "#e5e7eb" }}>
|
||||
<strong>Brand Avatar:</strong> Use your configured brand avatar for consistency.<br/>
|
||||
<strong>Asset Library:</strong> Choose from your previously uploaded images.<br/>
|
||||
<strong>Take a Selfie:</strong> Use your camera to capture a photo instantly.<br/>
|
||||
<strong>Upload your photo:</strong> We'll enhance it into a professional presenter.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<InfoIcon fontSize="small" sx={{ color: "#94a3b8", cursor: "help" }} />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Content Area */}
|
||||
<Stack direction={{ xs: "column", lg: "row" }} spacing={3} alignItems="flex-start" sx={{ p: 2.5 }}>
|
||||
{/* Left Side: Content based on selected tab */}
|
||||
<Box sx={{ flex: 1, width: "100%" }}>
|
||||
|
||||
{avatarTab === 0 && (
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ minHeight: { xs: 160, sm: 200 }, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", bgcolor: "#f8fafc", borderRadius: 2, p: { xs: 1.5, sm: 2 }, position: "relative" }}>
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
IconButton,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Checkbox,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Newspaper as NewspaperIcon,
|
||||
ShowChart as ShowChartIcon,
|
||||
School as SchoolIcon,
|
||||
Public as PublicIcon,
|
||||
Close as CloseIcon,
|
||||
OpenInNew as OpenInNewIcon,
|
||||
Refresh as RefreshIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
Search as SearchIcon,
|
||||
Language as LanguageIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
interface CategoryTopic {
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
favicon?: string;
|
||||
}
|
||||
|
||||
type CategoryType = "news" | "finance" | "research-paper" | "personal-site";
|
||||
|
||||
interface CategoryResearchModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
category: CategoryType;
|
||||
keyword?: string;
|
||||
websiteUrl?: string;
|
||||
loading?: boolean;
|
||||
topics?: CategoryTopic[];
|
||||
error?: string | null;
|
||||
onSelectTopic: (topic: string) => void;
|
||||
onRedoSearch?: (keyword: string, websiteUrl?: string) => void;
|
||||
onConfirmSelection?: (selectedTopics: string[]) => void;
|
||||
isCached?: boolean;
|
||||
}
|
||||
|
||||
const CATEGORY_CONFIG: Record<CategoryType, { label: string; icon: React.ReactNode; color: string; bgLight: string }> = {
|
||||
"news": { label: "News", icon: <NewspaperIcon />, color: "#4F46E5", bgLight: "#EEF2FF" },
|
||||
"finance": { label: "Finance", icon: <ShowChartIcon />, color: "#059669", bgLight: "#ECFDF5" },
|
||||
"research-paper": { label: "Research Papers", icon: <SchoolIcon />, color: "#7C3AED", bgLight: "#F3E8FF" },
|
||||
"personal-site": { label: "Personal Website", icon: <PublicIcon />, color: "#D97706", bgLight: "#FEF3C7" },
|
||||
};
|
||||
|
||||
const BEST_PRACTICES: Record<CategoryType, string[]> = {
|
||||
"news": [
|
||||
"Use specific, focused keywords for better results",
|
||||
"Include relevant industry or niche terms",
|
||||
"Add location or timeframe for localized news",
|
||||
"Avoid very general terms like 'news' or 'updates'",
|
||||
],
|
||||
"finance": [
|
||||
"Use specific, focused keywords for better results",
|
||||
"Include asset class (stocks, crypto, forex, bonds)",
|
||||
"Add timeframe (q1 2024, last month, etc.)",
|
||||
"Include market or sector names for targeted results",
|
||||
],
|
||||
"research-paper": [
|
||||
"Use academic keywords and terminology",
|
||||
"Include specific topics or research areas",
|
||||
"Add field of study (AI, medicine, climate, etc.)",
|
||||
"Works best with technical or scientific topics",
|
||||
],
|
||||
"personal-site": [
|
||||
"Enter the website URL in the input field below",
|
||||
"The search will find content within that domain",
|
||||
"Use specific page or topic keywords for best results",
|
||||
"Leave keyword empty to get all pages from the site",
|
||||
],
|
||||
};
|
||||
|
||||
export const CategoryResearchModal: React.FC<CategoryResearchModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
category,
|
||||
keyword,
|
||||
websiteUrl = "",
|
||||
loading = false,
|
||||
topics = [],
|
||||
error = null,
|
||||
onSelectTopic,
|
||||
onRedoSearch,
|
||||
onConfirmSelection,
|
||||
isCached = false,
|
||||
}) => {
|
||||
const config = CATEGORY_CONFIG[category];
|
||||
const categoryLabel = config.label;
|
||||
const categoryIcon = config.icon;
|
||||
const categoryColor = config.color;
|
||||
const categoryBgLight = config.bgLight;
|
||||
|
||||
const [redoKeyword, setRedoKeyword] = useState(keyword || "");
|
||||
const [localWebsiteUrl, setLocalWebsiteUrl] = useState(websiteUrl);
|
||||
const [selectedTopics, setSelectedTopics] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRedoKeyword(keyword || "");
|
||||
setLocalWebsiteUrl(websiteUrl || "");
|
||||
setSelectedTopics(new Set());
|
||||
}
|
||||
}, [open, keyword, websiteUrl]);
|
||||
|
||||
const handleSelectTopic = (topic: CategoryTopic) => {
|
||||
onSelectTopic(topic.title);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRedoClick = () => {
|
||||
if (onRedoSearch && redoKeyword.trim()) {
|
||||
onRedoSearch(redoKeyword.trim(), category === "personal-site" ? localWebsiteUrl : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSelectTopic = (title: string) => {
|
||||
const newSelected = new Set(selectedTopics);
|
||||
if (newSelected.has(title)) {
|
||||
newSelected.delete(title);
|
||||
} else {
|
||||
newSelected.add(title);
|
||||
}
|
||||
setSelectedTopics(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const allTitles = new Set(topics.map(t => t.title));
|
||||
setSelectedTopics(allTitles);
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setSelectedTopics(new Set());
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onConfirmSelection && selectedTopics.size > 0) {
|
||||
onConfirmSelection(Array.from(selectedTopics));
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const getDomain = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname.replace("www.", "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
const isPersonalSite = category === "personal-site";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
|
||||
background: "#ffffff",
|
||||
backgroundImage: "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
pb: 1.5,
|
||||
pt: 2.5,
|
||||
px: 3,
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
background: "#fafafa",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 2.5,
|
||||
background: `linear-gradient(135deg, ${categoryColor}08 0%, ${categoryColor}04 100%)`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: categoryColor,
|
||||
}}
|
||||
>
|
||||
{categoryIcon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: "1.25rem", lineHeight: 1.3, color: "#111827" }}>
|
||||
{categoryLabel}
|
||||
</Typography>
|
||||
{keyword && (
|
||||
<Typography variant="body2" sx={{ color: "#6b7280", fontSize: "0.875rem", mt: 0.25 }}>
|
||||
Searching: <Typography component="span" sx={{ fontWeight: 600, color: "#374151" }}>{keyword}</Typography>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: "#fff" }}>
|
||||
Best Practices for Search
|
||||
</Typography>
|
||||
{BEST_PRACTICES[category].map((tip, idx) => (
|
||||
<Typography key={idx} variant="caption" sx={{ display: "block", mb: 0.5, color: "#e5e7eb" }}>
|
||||
• {tip}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="bottom-end"
|
||||
>
|
||||
<Chip
|
||||
icon={<LightbulbIcon sx={{ fontSize: "14px !important" }} />}
|
||||
label="For best results"
|
||||
size="small"
|
||||
sx={{
|
||||
background: categoryBgLight,
|
||||
color: categoryColor,
|
||||
border: `1px solid ${categoryColor}25`,
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "help",
|
||||
"& .MuiChip-icon": { color: categoryColor },
|
||||
"&:hover": {
|
||||
background: `${categoryColor}15`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton onClick={handleClose} size="small" sx={{ color: "#9ca3af" }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ pt: 3, pb: 2, px: 3, minHeight: 360 }}>
|
||||
{loading && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", py: 8 }}>
|
||||
<CircularProgress size={48} sx={{ color: categoryColor, mb: 2.5 }} />
|
||||
<Typography variant="h6" sx={{ color: "#374151", fontWeight: 600, mb: 0.5 }}>
|
||||
Searching {categoryLabel.toLowerCase()}...
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#6b7280" }}>
|
||||
{isPersonalSite
|
||||
? `Searching within ${localWebsiteUrl || "your website"}`
|
||||
: `Finding relevant ${categoryLabel.toLowerCase()} for your podcast`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: "#fef2f2",
|
||||
border: "1px solid #fecaca",
|
||||
color: "#dc2626",
|
||||
"& .MuiAlert-icon": { color: "#dc2626" }
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && topics.length === 0 && (
|
||||
<Box sx={{ py: 6, textAlign: "center" }}>
|
||||
<Box sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: "50%",
|
||||
background: "#f3f4f6",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
mx: "auto",
|
||||
mb: 2
|
||||
}}>
|
||||
{React.cloneElement(categoryIcon as React.ReactElement, { sx: { fontSize: 32, color: "#d1d5db" } })}
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ color: "#374151", fontWeight: 600, mb: 0.5 }}>
|
||||
No results found
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#6b7280" }}>
|
||||
{isPersonalSite
|
||||
? "Enter a website URL and try different keywords"
|
||||
: "Try different search terms or redo search"}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && !error && topics.length > 0 && (
|
||||
<>
|
||||
{/* Redo Search Bar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
mb: 2,
|
||||
background: "#f9fafb",
|
||||
borderRadius: 2,
|
||||
border: "1px solid #e5e7eb",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<RefreshIcon sx={{ fontSize: 18, color: categoryColor }} />
|
||||
<Typography variant="body2" sx={{ color: "#374151", fontWeight: 500, fontSize: "0.875rem", flexShrink: 0 }}>
|
||||
Search again
|
||||
</Typography>
|
||||
|
||||
{/* Website URL input for Personal Site */}
|
||||
{isPersonalSite && (
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Enter website URL (e.g., example.com)"
|
||||
value={localWebsiteUrl}
|
||||
onChange={(e) => setLocalWebsiteUrl(e.target.value)}
|
||||
sx={{
|
||||
width: 260,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
background: "#fff",
|
||||
fontSize: "0.8rem",
|
||||
height: 34,
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "#d1d5db",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Enter search term..."
|
||||
value={redoKeyword}
|
||||
onChange={(e) => setRedoKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRedoClick()}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 150,
|
||||
maxWidth: 280,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
background: "#fff",
|
||||
fontSize: "0.8rem",
|
||||
height: 34,
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "#d1d5db",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={handleRedoClick}
|
||||
disabled={!redoKeyword.trim() || (isPersonalSite && !localWebsiteUrl.trim())}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
background: categoryColor,
|
||||
borderRadius: 1.5,
|
||||
px: 1.5,
|
||||
height: 34,
|
||||
"&:hover": {
|
||||
background: categoryColor,
|
||||
opacity: 0.9,
|
||||
},
|
||||
"&:disabled": {
|
||||
background: "#e5e7eb",
|
||||
color: "#9ca3af",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Select All / Deselect All */}
|
||||
{topics.length > 0 && (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1.5, justifyContent: "flex-end" }}>
|
||||
<Typography variant="body2" sx={{ color: "#6b7280", fontSize: "0.8rem", mr: 1 }}>
|
||||
{selectedTopics.size} of {topics.length} selected
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleSelectAll}
|
||||
sx={{ textTransform: "none", fontSize: "0.75rem", fontWeight: 600, color: categoryColor, minWidth: "auto", px: 1 }}
|
||||
>
|
||||
Select All
|
||||
</Button>
|
||||
<Typography variant="body2" sx={{ color: "#d1d5db" }}>|</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleDeselectAll}
|
||||
disabled={selectedTopics.size === 0}
|
||||
sx={{ textTransform: "none", fontSize: "0.75rem", fontWeight: 600, color: "#6b7280", minWidth: "auto", px: 1 }}
|
||||
>
|
||||
Deselect All
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
{topics.map((topic, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: selectedTopics.has(topic.title)
|
||||
? `2px solid ${categoryColor}`
|
||||
: "1px solid #e5e7eb",
|
||||
background: selectedTopics.has(topic.title)
|
||||
? categoryBgLight
|
||||
: "#ffffff",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
"&:hover": {
|
||||
background: "#f9fafb",
|
||||
borderColor: categoryColor,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5 }}>
|
||||
<Checkbox
|
||||
checked={selectedTopics.has(topic.title)}
|
||||
onChange={() => handleToggleSelectTopic(topic.title)}
|
||||
sx={{
|
||||
p: 0,
|
||||
mt: 0.25,
|
||||
color: "#d1d5db",
|
||||
"&.Mui-checked": { color: categoryColor },
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} onClick={() => handleSelectTopic(topic)}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.95rem",
|
||||
lineHeight: 1.5,
|
||||
mb: 0.5,
|
||||
color: "#111827",
|
||||
}}
|
||||
>
|
||||
{topic.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "#4b5563",
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: 1.5,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{topic.snippet}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mt: 1 }}>
|
||||
{topic.favicon && (
|
||||
<Box
|
||||
component="img"
|
||||
src={topic.favicon}
|
||||
alt=""
|
||||
sx={{ width: 14, height: 14, borderRadius: 0.5 }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Typography variant="body2" sx={{ color: "#6b7280", fontSize: "0.75rem" }}>
|
||||
{getDomain(topic.url)}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${Math.round(topic.score * 100)}%`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 600,
|
||||
background: `${categoryColor}12`,
|
||||
color: categoryColor,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<OpenInNewIcon sx={{ fontSize: 14, color: "#9ca3af", flexShrink: 0, mt: 0.5 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
background: "#f9fafb",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#9ca3af", fontSize: "0.8rem" }}>
|
||||
{topics.length} results • {category === "news" || category === "finance" ? "Powered by Tavily" : "Powered by Exa"}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1.5 }}>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.875rem",
|
||||
color: "#6b7280",
|
||||
background: "#ffffff",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 2,
|
||||
px: 2.5,
|
||||
py: 0.75,
|
||||
"&:hover": {
|
||||
background: "#f3f4f6",
|
||||
borderColor: "#9ca3af",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedTopics.size === 0}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.875rem",
|
||||
color: "#fff",
|
||||
background: selectedTopics.size > 0
|
||||
? `linear-gradient(135deg, ${categoryColor} 0%, ${categoryColor}dd 100%)`
|
||||
: "#e5e7eb",
|
||||
borderRadius: 2,
|
||||
px: 2.5,
|
||||
py: 0.75,
|
||||
"&:hover": {
|
||||
background: categoryColor,
|
||||
opacity: 0.9,
|
||||
},
|
||||
"&:disabled": {
|
||||
background: "#e5e7eb",
|
||||
color: "#9ca3af",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Use {selectedTopics.size > 0 ? `${selectedTopics.size} ` : ""}Selected for Podcast
|
||||
</Button>
|
||||
</Box>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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'));
|
||||
|
||||
@@ -392,28 +397,33 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Close modal when analysis completes OR when there's an error
|
||||
// Use a ref to track previous isSubmitting to detect the transition from true to false
|
||||
const prevIsSubmittingRef = useRef(isSubmitting);
|
||||
// Close modal only AFTER analysis fully completes (wait for project/analysis to be set)
|
||||
useEffect(() => {
|
||||
// Detect transition from submitting to not submitting (analysis complete)
|
||||
// Track if analysis transitioned from true to false (completed)
|
||||
const wasSubmitting = prevIsSubmittingRef.current;
|
||||
const nowNotSubmitting = !isSubmitting;
|
||||
|
||||
if (showAnalysisModal && analysisStarted && wasSubmitting && nowNotSubmitting) {
|
||||
console.warn('[CreateActions] Analysis complete — closing modal and clearing announcement');
|
||||
// Only close modal if:
|
||||
// 1. Modal is still shown
|
||||
// 2. analysisStarted is true
|
||||
// 3. isSubmitting transitioned from true to false
|
||||
// 4. AND we're not showing an error
|
||||
if (showAnalysisModal && analysisStarted && wasSubmitting && nowNotSubmitting && !error) {
|
||||
// Mark analysis as complete and close after a delay
|
||||
setAnalysisCompleteRef(true);
|
||||
console.warn('[CreateActions] Analysis complete — will close modal after delay');
|
||||
setTimeout(() => {
|
||||
setShowAnalysisModal(false);
|
||||
onAnnouncementClear?.();
|
||||
}, 100);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Update ref for next render
|
||||
prevIsSubmittingRef.current = isSubmitting;
|
||||
|
||||
// If there's an error, also ensure modal is usable
|
||||
// If there's an error, keep modal open so user can see error message
|
||||
if (error && showAnalysisModal) {
|
||||
console.warn('[CreateActions] Error detected:', error);
|
||||
console.warn('[CreateActions] Error detected — keeping modal open:', error);
|
||||
}
|
||||
}, [isSubmitting, showAnalysisModal, analysisStarted, onAnnouncementClear, error]);
|
||||
|
||||
@@ -464,7 +474,7 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
tooltip={!canSubmit ? "Complete all 4 steps: 1) Enter topic/URL, 2) Configure duration & speakers, 3) Add avatar, 4) Select voice" : "We'll start AI analysis after this click"}
|
||||
tooltip={!canSubmit ? "Required: Podcast topic, presenter avatar, voice, duration, speakers, and podcast mode" : "Start AI analysis after this click"}
|
||||
>
|
||||
{isSubmitting ? "Analyzing..." : buttonText}
|
||||
</PrimaryButton>
|
||||
@@ -472,7 +482,14 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
|
||||
|
||||
<Dialog
|
||||
open={showAnalysisModal}
|
||||
onClose={() => !isSubmitting && setShowAnalysisModal(false)}
|
||||
disableEscapeKeyDown={isSubmitting}
|
||||
onClose={(event, reason) => {
|
||||
// Only allow closing if NOT submitting and analysis hasn't started
|
||||
// This prevents modal from closing when user clicks outside while analysis runs
|
||||
if (!isSubmitting && !analysisStarted) {
|
||||
setShowAnalysisModal(false);
|
||||
}
|
||||
}}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
fullScreen={isMobile}
|
||||
|
||||
@@ -57,14 +57,14 @@ export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
|
||||
sx={{
|
||||
flex: { xs: "1 1 auto", lg: "0 0 320px" },
|
||||
width: { xs: "100%", lg: "320px" },
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(102, 126, 234, 0.15)",
|
||||
border: "1px solid",
|
||||
borderColor: "#e2e8f0",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 4px 20px rgba(102, 126, 234, 0.08)",
|
||||
boxShadow: "0 8px 30px rgba(15, 23, 42, 0.12)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
"&::before": {
|
||||
@@ -78,49 +78,47 @@ export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2.5 }}>
|
||||
{/* Header with gradient background */}
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 2, px: 3, pt: 3, pb: 2, background: "linear-gradient(180deg, #eff6ff 0%, #f0f9ff 60%, #ffffff 100%)", borderBottom: "1px solid #e0e7ff" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: "#fff", fontSize: "0.75rem", fontWeight: 700 }}>2</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1.5,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.15)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
<SettingsIcon sx={{ color: "#667eea", fontSize: "1rem" }} />
|
||||
<SettingsIcon sx={{ color: "#6366f1", fontSize: "1.1rem" }} />
|
||||
</Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
fontWeight: 700,
|
||||
fontSize: "1rem",
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700, fontSize: "1rem", letterSpacing: "-0.01em" }}>
|
||||
Basic Configuration
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.75rem", display: "block", mt: -0.25 }}>
|
||||
Set duration, speakers, and podcast mode
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={3} sx={{ p: 3, pt: 2 }}>
|
||||
{/* Podcast Mode */}
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, TextField, Tooltip, Button, CircularProgress, alpha, Stack, Chip } from "@mui/material";
|
||||
import { AutoAwesome as AutoAwesomeIcon, AttachMoney as AttachMoneyIcon } from "@mui/icons-material";
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Box, Typography, TextField, Tooltip, Button, CircularProgress, alpha, Stack, Chip, IconButton, Collapse } from "@mui/material";
|
||||
import { AutoAwesome as AutoAwesomeIcon, AttachMoney as AttachMoneyIcon, TrendingUp as TrendingUpIcon, Mic as MicIcon, Stop as StopIcon, Language as LanguageIcon, Newspaper as NewspaperIcon, ShowChart as ShowChartIcon, School as SchoolIcon, Public as PublicIcon, Lightbulb as LightbulbIcon } from "@mui/icons-material";
|
||||
import { Knobs } from "../types";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
import { WebsitePreviewModal } from "./WebsitePreviewModal";
|
||||
|
||||
export const TOPIC_PLACEHOLDERS = [
|
||||
"Industry insights: Latest trends in AI for Content Marketing",
|
||||
@@ -18,19 +20,58 @@ interface TopicUrlInputProps {
|
||||
isUrl: boolean;
|
||||
showAIDetailsButton: boolean;
|
||||
onAIDetailsClick?: () => void;
|
||||
onTrendingTopicsClick?: () => void;
|
||||
onCategoryResearchClick?: (category: "news" | "finance" | "research-paper" | "personal-site", websiteUrl?: string) => void;
|
||||
placeholderIndex: number;
|
||||
loading?: boolean;
|
||||
loadingMessage?: string;
|
||||
estimatedCost?: {
|
||||
trendingLoading?: boolean;
|
||||
categoryResearchLoading?: boolean;
|
||||
// Estimated cost - can be a number (from pre-estimate) or object (from analyze response)
|
||||
estimatedCost?: number | {
|
||||
ttsCost: number;
|
||||
avatarCost: number;
|
||||
videoCost: number;
|
||||
researchCost: number;
|
||||
total: number;
|
||||
};
|
||||
} | null;
|
||||
duration?: number;
|
||||
speakers?: number;
|
||||
knobs?: Knobs;
|
||||
podcastMode?: string;
|
||||
// Website extraction data - passed from parent for use with AI enhance
|
||||
extractedData?: {
|
||||
title?: string;
|
||||
text?: string;
|
||||
summary?: string;
|
||||
highlights?: string[];
|
||||
url: string;
|
||||
image?: string;
|
||||
favicon?: string;
|
||||
subpages?: Array<{id?: string; title?: string; url?: string; summary?: string; text?: string}>;
|
||||
} | null;
|
||||
setExtractedData?: (data: any) => void;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionType {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
maxAlternatives: number;
|
||||
onresult: ((event: { results: { isFinal: boolean; [index: number]: { transcript: string } }[], resultIndex: number }) => void) | null;
|
||||
onerror: ((event: { error: string }) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onstart: (() => void) | null;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition: new () => SpeechRecognitionType;
|
||||
webkitSpeechRecognition: new () => SpeechRecognitionType;
|
||||
}
|
||||
}
|
||||
|
||||
export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
||||
@@ -39,25 +80,162 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
||||
isUrl,
|
||||
showAIDetailsButton,
|
||||
onAIDetailsClick,
|
||||
onTrendingTopicsClick,
|
||||
onCategoryResearchClick,
|
||||
placeholderIndex,
|
||||
loading = false,
|
||||
loadingMessage,
|
||||
trendingLoading = false,
|
||||
categoryResearchLoading = false,
|
||||
estimatedCost,
|
||||
duration = 1,
|
||||
speakers = 1,
|
||||
knobs,
|
||||
podcastMode = "audio_video",
|
||||
extractedData: extractedDataProp,
|
||||
setExtractedData: setExtractedDataProp,
|
||||
}) => {
|
||||
// Helper to get total cost from various estimate formats (number | object | null)
|
||||
const getTotalCost = (cost: number | { total: number } | null | undefined): number | null => {
|
||||
if (cost === null || cost === undefined) return null;
|
||||
if (typeof cost === "number") return cost;
|
||||
if (typeof cost === "object" && "total" in cost) return cost.total;
|
||||
return null;
|
||||
};
|
||||
|
||||
const totalCost = getTotalCost(estimatedCost);
|
||||
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const recognitionRef = useRef<SpeechRecognitionType | null>(null);
|
||||
|
||||
// Use props if provided, otherwise use local state (for backward compatibility)
|
||||
const [localExtractedData, setLocalExtractedData] = useState<any>(null);
|
||||
const _extractedData = extractedDataProp !== undefined ? extractedDataProp : localExtractedData;
|
||||
const _setExtractedData = setExtractedDataProp || setLocalExtractedData;
|
||||
|
||||
// Website extraction state
|
||||
const [showWebsiteInput, setShowWebsiteInput] = useState(false);
|
||||
const [websiteUrl, setWebsiteUrl] = useState("");
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
const [extractedData, setExtractedData] = useState<{title?: string; text?: string; summary?: string; highlights?: string[]; url: string; image?: string; favicon?: string; subpages?: Array<{id?: string; title?: string; url?: string; summary?: string; text?: string}>} | null>(null);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [websiteError, setWebsiteError] = useState<string | null>(null);
|
||||
|
||||
const isSupported = typeof window !== 'undefined' && (window.SpeechRecognition !== undefined || window.webkitSpeechRecognition !== undefined);
|
||||
|
||||
const getBrowserLanguage = (): string => {
|
||||
const lang = (navigator.language || '').toLowerCase();
|
||||
if (lang.startsWith('en')) return 'en-US';
|
||||
if (lang.startsWith('hi')) return 'hi-IN';
|
||||
if (lang.startsWith('es')) return 'es-ES';
|
||||
if (lang.startsWith('fr')) return 'fr-FR';
|
||||
if (lang.startsWith('de')) return 'de-DE';
|
||||
if (lang.startsWith('zh')) return 'zh-CN';
|
||||
if (lang.startsWith('ja')) return 'ja-JP';
|
||||
if (lang.startsWith('ko')) return 'ko-KR';
|
||||
return 'en-US';
|
||||
};
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
if (!isSupported) {
|
||||
setError('Speech recognition is not supported in this browser. Try Chrome or Edge.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
const SpeechRecognitionAPI = window.SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
if (!recognitionRef.current) {
|
||||
const recognition = new SpeechRecognitionAPI() as SpeechRecognitionType;
|
||||
recognition.lang = getBrowserLanguage();
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = true;
|
||||
recognition.maxAlternatives = 1;
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
let transcript = '';
|
||||
let isFinal = false;
|
||||
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
isFinal = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFinal) {
|
||||
const newValue = value ? `${value} ${transcript.trim()}`.trim() : transcript.trim();
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = (event) => {
|
||||
console.error('[Speech] Error:', event.error);
|
||||
if (event.error === 'not-allowed') {
|
||||
setError('Microphone access denied. Please allow microphone access in your browser settings.');
|
||||
} else if (event.error === 'network') {
|
||||
setError('Network error. Please check your internet connection.');
|
||||
} else if (event.error !== 'aborted') {
|
||||
setError(`Speech recognition error: ${event.error}`);
|
||||
}
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
}
|
||||
|
||||
recognitionRef.current.onstart = () => {
|
||||
setIsListening(true);
|
||||
};
|
||||
|
||||
try {
|
||||
recognitionRef.current.start();
|
||||
} catch (e) {
|
||||
console.error('[Speech] Start error:', e);
|
||||
setError('Failed to start speech recognition. Please try again.');
|
||||
}
|
||||
}, [isSupported, onChange, value]);
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.stop();
|
||||
}
|
||||
setIsListening(false);
|
||||
}, []);
|
||||
|
||||
const handleMicClick = useCallback(() => {
|
||||
if (isListening) {
|
||||
stopListening();
|
||||
} else {
|
||||
startListening();
|
||||
}
|
||||
}, [isListening, stopListening, startListening]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
p: 0,
|
||||
borderRadius: 3,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(102, 126, 234, 0.15)",
|
||||
border: "1px solid",
|
||||
borderColor: "#e2e8f0",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 4px 20px rgba(102, 126, 234, 0.08)",
|
||||
boxShadow: "0 8px 30px rgba(15, 23, 42, 0.12)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
"&::before": {
|
||||
@@ -71,113 +249,198 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box flex={1} display="flex" flexDirection="column">
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
{/* Header with gradient background */}
|
||||
<Box flex={1} display="flex" flexDirection="column" sx={{ background: "linear-gradient(180deg, #eff6ff 0%, #f0f9ff 60%, #ffffff 100%)", px: 3, pt: 3, pb: 2, borderBottom: "1px solid #e0e7ff" }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1.5 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: "#fff", fontSize: "0.75rem", fontWeight: 700 }}>1</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1.5,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.15)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ color: "#667eea", fontSize: "1rem" }} />
|
||||
<LightbulbIcon sx={{ color: "#6366f1", fontSize: "1.1rem" }} />
|
||||
</Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
fontWeight: 700,
|
||||
fontSize: "1rem",
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
Enter Podcast Topic or Blog URL
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{estimatedCost && (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
{!showWebsiteInput && (
|
||||
<Chip
|
||||
icon={<LanguageIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||
label="Your Website"
|
||||
onClick={() => setShowWebsiteInput(true)}
|
||||
disabled={loading}
|
||||
size="small"
|
||||
sx={{
|
||||
background: "rgba(102, 126, 234, 0.08)",
|
||||
color: "#667eea",
|
||||
border: "1px solid rgba(102, 126, 234, 0.25)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
"&:hover": {
|
||||
background: "rgba(102, 126, 234, 0.15)",
|
||||
transform: "scale(1.02)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
totalCost && estimatedCost ? (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Estimated Cost Breakdown:
|
||||
Estimated Cost:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
|
||||
• Audio Generation: ${estimatedCost.ttsCost}<br />
|
||||
• Avatar Creation: ${estimatedCost.avatarCost}<br />
|
||||
• Video Rendering: ${estimatedCost.videoCost}<br />
|
||||
• Research: ${estimatedCost.researchCost}<br />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mt: 0.5, pt: 0.5, borderTop: "1px solid rgba(255,255,255,0.2)" }}>
|
||||
Total: ${estimatedCost.total}
|
||||
Total: ${totalCost}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: "0.75rem", opacity: 0.9, mt: 0.5, display: "block" }}>
|
||||
Based on {duration} min, {speakers} speaker{speakers > 1 ? "s" : ""}, {knobs?.bitrate === "hd" ? "HD" : "standard"} quality
|
||||
</Typography>
|
||||
Based on {duration} min, {speakers} speaker{speakers > 1 ? "s" : ""}, {podcastMode} mode
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
"Estimate unavailable. Pricing data not found."
|
||||
)
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
bgcolor: "#0f172a",
|
||||
color: "#ffffff",
|
||||
maxWidth: 280,
|
||||
fontSize: "0.875rem",
|
||||
p: 1.5,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
},
|
||||
},
|
||||
arrow: {
|
||||
sx: {
|
||||
color: "#0f172a",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||
label={`Est. $${estimatedCost.total}`}
|
||||
label={totalCost ? `Est. $${totalCost}` : "Est. Unavailable"}
|
||||
size="small"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)",
|
||||
color: "#059669",
|
||||
background: totalCost ? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)" : "rgba(100, 116, 139, 0.12)",
|
||||
color: totalCost ? "#059669" : "#475569",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(16, 185, 129, 0.2)",
|
||||
border: totalCost ? "1px solid rgba(16, 185, 129, 0.2)" : "1px solid rgba(100, 116, 139, 0.25)",
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
cursor: "help",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Website input row - appears when user clicks "Your Website" chip */}
|
||||
<Collapse in={showWebsiteInput}>
|
||||
<Box sx={{ mt: 1.5, mb: 1.5, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="https://yourdomain.com (enter your website home page)"
|
||||
value={websiteUrl}
|
||||
onChange={(e) => setWebsiteUrl(e.target.value)}
|
||||
disabled={isExtracting}
|
||||
error={!!websiteError}
|
||||
helperText={websiteError}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
fontSize: "0.875rem",
|
||||
"&.Mui-focused": {
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
if (!websiteUrl.trim()) {
|
||||
setWebsiteError("Please enter a website URL");
|
||||
return;
|
||||
}
|
||||
setWebsiteError(null);
|
||||
setIsExtracting(true);
|
||||
try {
|
||||
const result = await podcastApi.extractUrl({ url: websiteUrl.trim() });
|
||||
if (result.success) {
|
||||
const extractionData = {
|
||||
title: result.title || "",
|
||||
text: result.text || "",
|
||||
summary: result.summary || "",
|
||||
highlights: result.highlights || [],
|
||||
url: result.url,
|
||||
image: result.image || undefined,
|
||||
favicon: result.favicon || undefined,
|
||||
subpages: result.subpages || [],
|
||||
};
|
||||
_setExtractedData(extractionData);
|
||||
|
||||
// Save to backend for future use
|
||||
try {
|
||||
await podcastApi.saveWebsiteExtraction({
|
||||
title: extractionData.title,
|
||||
text: extractionData.text,
|
||||
summary: extractionData.summary,
|
||||
highlights: extractionData.highlights,
|
||||
url: extractionData.url,
|
||||
subpages: extractionData.subpages,
|
||||
});
|
||||
} catch (saveErr) {
|
||||
console.warn("[TopicUrlInput] Failed to save extraction:", saveErr);
|
||||
}
|
||||
|
||||
setShowPreviewModal(true);
|
||||
} else {
|
||||
setWebsiteError(result.error || "Failed to extract content");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setWebsiteError(err?.message || "Failed to extract content");
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
}}
|
||||
disabled={isExtracting || !websiteUrl.trim()}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8125rem",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
whiteSpace: "nowrap",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #7c8ff0 0%, #8a5cb3 100%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isExtracting ? <CircularProgress size={16} sx={{ color: "#fff" }} /> : "Extract"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<Tooltip
|
||||
title={
|
||||
isUrl
|
||||
isListening
|
||||
? "Listening... Click the mic to stop."
|
||||
: isUrl
|
||||
? "We detected a URL. We'll fetch insights from this page."
|
||||
: "Enter a concise idea or paste a blog URL."
|
||||
: "Enter a concise idea, paste a blog URL, or click the mic to speak your topic."
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
@@ -196,28 +459,35 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
size="small"
|
||||
disabled={isListening}
|
||||
helperText={
|
||||
isUrl
|
||||
error
|
||||
? error
|
||||
: isListening
|
||||
? "Listening... Speak your topic now."
|
||||
: isUrl
|
||||
? "URL detected. We'll analyze this page content."
|
||||
: "Enter a clear, concise topic. We'll expand it into a full script after you click Analyze."
|
||||
: "Enter a clear, concise topic. You can also click the mic to speak."
|
||||
}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
border: "2px solid rgba(102, 126, 234, 0.2)",
|
||||
backgroundColor: isListening ? "rgba(16, 185, 129, 0.04)" : "#f8fafc",
|
||||
border: isListening ? "2px solid rgba(16, 185, 129, 0.5)" : "2px solid rgba(102, 126, 234, 0.2)",
|
||||
borderRadius: 2,
|
||||
fontSize: "1rem",
|
||||
transition: "all 0.2s ease",
|
||||
"&:hover": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.1)",
|
||||
borderColor: isListening ? "rgba(16, 185, 129, 0.7)" : "rgba(102, 126, 234, 0.4)",
|
||||
boxShadow: isListening ? "0 2px 8px rgba(16, 185, 129, 0.15)" : "0 2px 8px rgba(102, 126, 234, 0.1)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: isUrl ? "#10b981" : "#667eea",
|
||||
borderColor: isListening ? "#10b981" : isUrl ? "#10b981" : "#667eea",
|
||||
borderWidth: 2,
|
||||
boxShadow: isUrl
|
||||
boxShadow: isListening
|
||||
? "0 0 0 4px rgba(16, 185, 129, 0.1)"
|
||||
: isUrl
|
||||
? "0 0 0 4px rgba(16, 185, 129, 0.1)"
|
||||
: "0 0 0 4px rgba(102, 126, 234, 0.1)",
|
||||
},
|
||||
@@ -234,7 +504,7 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
||||
},
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: isUrl ? "#059669" : "#64748b",
|
||||
color: error ? "#ef4444" : isListening ? "#059669" : isUrl ? "#059669" : "#64748b",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 500,
|
||||
mt: 1,
|
||||
@@ -243,9 +513,197 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Enhance topic with AI button - appears when user types (and not a URL) */}
|
||||
{/* Mic button with listening indicator - positioned inside the textarea bottom-right */}
|
||||
{isSupported && !loading && (
|
||||
<Box sx={{ position: "absolute", bottom: isListening ? 32 : 44, right: 4, zIndex: 2, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{isListening && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
color: "#059669",
|
||||
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
border: "1px solid rgba(16, 185, 129, 0.2)",
|
||||
whiteSpace: "nowrap",
|
||||
animation: "fadeIn 0.2s ease",
|
||||
"@keyframes fadeIn": {
|
||||
from: { opacity: 0, transform: "translateX(4px)" },
|
||||
to: { opacity: 1, transform: "translateX(0)" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
Listening...
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={handleMicClick}
|
||||
disabled={loading}
|
||||
size="small"
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
background: isListening
|
||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||
: "linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%)",
|
||||
color: isListening ? "#fff" : "#667eea",
|
||||
border: isListening
|
||||
? "2px solid rgba(16, 185, 129, 0.3)"
|
||||
: "1px solid rgba(102, 126, 234, 0.25)",
|
||||
boxShadow: isListening
|
||||
? "0 0 0 4px rgba(16, 185, 129, 0.15), 0 2px 8px rgba(16, 185, 129, 0.3)"
|
||||
: "0 2px 6px rgba(102, 126, 234, 0.15)",
|
||||
animation: isListening ? "pulse-mic 1.5s ease-in-out infinite" : "none",
|
||||
"&:hover": {
|
||||
background: isListening
|
||||
? "linear-gradient(135deg, #34d399 0%, #10b981 100%)"
|
||||
: "linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%)",
|
||||
transform: "scale(1.05)",
|
||||
},
|
||||
"&.Mui-disabled": {
|
||||
background: "rgba(100, 116, 139, 0.08)",
|
||||
color: "#94a3b8",
|
||||
border: "1px solid rgba(100, 116, 139, 0.15)",
|
||||
},
|
||||
"@keyframes pulse-mic": {
|
||||
"0%": { boxShadow: "0 0 0 4px rgba(16, 185, 129, 0.15), 0 2px 8px rgba(16, 185, 129, 0.3)" },
|
||||
"50%": { boxShadow: "0 0 0 8px rgba(16, 185, 129, 0.08), 0 2px 12px rgba(16, 185, 129, 0.4)" },
|
||||
"100%": { boxShadow: "0 0 0 4px rgba(16, 185, 129, 0.15), 0 2px 8px rgba(16, 185, 129, 0.3)" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isListening ? (
|
||||
<StopIcon sx={{ fontSize: "1.1rem" }} />
|
||||
) : (
|
||||
<MicIcon sx={{ fontSize: "1.1rem" }} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Category Research Chips - News + Finance + Research Papers + Personal Website */}
|
||||
{showAIDetailsButton && !isUrl && onCategoryResearchClick && (
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-start", mt: 1.5, gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip
|
||||
icon={categoryResearchLoading ? <CircularProgress size={14} sx={{ color: "#667eea !important" }} /> : <NewspaperIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||
label="News"
|
||||
onClick={() => onCategoryResearchClick("news")}
|
||||
disabled={categoryResearchLoading || loading}
|
||||
size="small"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%)",
|
||||
color: "#667eea",
|
||||
border: "1px solid rgba(102, 126, 234, 0.3)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8125rem",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%)",
|
||||
transform: "scale(1.02)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
icon={categoryResearchLoading ? <CircularProgress size={14} sx={{ color: "#10b981 !important" }} /> : <ShowChartIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||
label="Finance"
|
||||
onClick={() => onCategoryResearchClick("finance")}
|
||||
disabled={categoryResearchLoading || loading}
|
||||
size="small"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)",
|
||||
color: "#10b981",
|
||||
border: "1px solid rgba(16, 185, 129, 0.3)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8125rem",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(5, 150, 105, 0.2) 100%)",
|
||||
transform: "scale(1.02)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
icon={categoryResearchLoading ? <CircularProgress size={14} sx={{ color: "#8b5cf6 !important" }} /> : <SchoolIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||
label="Research Papers"
|
||||
onClick={() => onCategoryResearchClick("research-paper")}
|
||||
disabled={categoryResearchLoading || loading}
|
||||
size="small"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(139, 92, 246, 0.1) 0%, rgba(124, 58, 237, 0.1) 100%)",
|
||||
color: "#8b5cf6",
|
||||
border: "1px solid rgba(139, 92, 246, 0.3)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8125rem",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, rgba(139, 92, 246, 0.2) 0%, rgba(124, 58, 237, 0.2) 100%)",
|
||||
transform: "scale(1.02)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
icon={categoryResearchLoading ? <CircularProgress size={14} sx={{ color: "#f59e0b !important" }} /> : <PublicIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||
label="Personal Site"
|
||||
onClick={() => onCategoryResearchClick("personal-site", value)}
|
||||
disabled={categoryResearchLoading || loading}
|
||||
size="small"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(217, 119, 6, 0.1) 100%)",
|
||||
color: "#f59e0b",
|
||||
border: "1px solid rgba(245, 158, 11, 0.3)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8125rem",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, rgba(245, 158, 11, 0.2) 0%, rgba(217, 119, 6, 0.2) 100%)",
|
||||
transform: "scale(1.02)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Enhance topic with AI button + Get Trending Topics - appears when user types (and not a URL) */}
|
||||
{showAIDetailsButton && !isUrl && (
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1.5, flexDirection: "column", alignItems: "flex-end", gap: 0.6 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1.5, flexDirection: { xs: "column", sm: "row" }, alignItems: { xs: "stretch", sm: "flex-end" }, gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={
|
||||
trendingLoading ? (
|
||||
<CircularProgress size={14} thickness={5} sx={{ color: "rgba(255,255,255,0.92)" }} />
|
||||
) : (
|
||||
<TrendingUpIcon />
|
||||
)
|
||||
}
|
||||
onClick={onTrendingTopicsClick}
|
||||
disabled={trendingLoading || loading}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 600,
|
||||
borderRadius: 2.5,
|
||||
color: "#f8fbff",
|
||||
px: 2,
|
||||
py: 0.75,
|
||||
border: "1px solid rgba(16, 185, 129, 0.4)",
|
||||
background: "linear-gradient(120deg, #10b981 0%, #059669 55%, #047857 100%)",
|
||||
boxShadow: "0 8px 18px rgba(16, 185, 129, 0.28), inset 0 1px 0 rgba(255,255,255,0.22)",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(120deg, #34d399 0%, #10b981 50%, #059669 100%)",
|
||||
boxShadow: "0 12px 24px rgba(16, 185, 129, 0.35), inset 0 1px 0 rgba(255,255,255,0.26)",
|
||||
transform: "translateY(-1px)",
|
||||
},
|
||||
"&.Mui-disabled": {
|
||||
color: "#e2e8f0",
|
||||
borderColor: "rgba(110, 231, 183, 0.7)",
|
||||
background: "linear-gradient(120deg, #10b981 0%, #059669 55%, #047857 100%)",
|
||||
opacity: 0.78,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{trendingLoading ? "Fetching Trends..." : "Get Trending Topics"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
@@ -257,7 +715,7 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
||||
)
|
||||
}
|
||||
onClick={onAIDetailsClick}
|
||||
disabled={loading}
|
||||
disabled={loading || trendingLoading}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontSize: "0.875rem",
|
||||
@@ -284,14 +742,40 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
||||
>
|
||||
{loading ? "Enhancing Topic With AI..." : "Enhance Topic With AI"}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{loading && (
|
||||
<Typography sx={{ fontSize: "0.75rem", color: "#1d4ed8", fontWeight: 600 }}>
|
||||
<Typography sx={{ fontSize: "0.75rem", color: "#1d4ed8", fontWeight: 600, mt: 0.5, textAlign: "right" }}>
|
||||
{loadingMessage || "Analyzing your topic and improving clarity..."}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Website Preview Modal */}
|
||||
<WebsitePreviewModal
|
||||
open={showPreviewModal}
|
||||
extractedData={_extractedData}
|
||||
onClose={() => {
|
||||
setShowPreviewModal(false);
|
||||
setShowWebsiteInput(false);
|
||||
setWebsiteUrl("");
|
||||
}}
|
||||
onUseTextOnly={() => {
|
||||
if (extractedData?.summary) {
|
||||
const newValue = extractedData.title
|
||||
? `${extractedData.title}: ${extractedData.summary}`
|
||||
: extractedData.summary;
|
||||
onChange(newValue);
|
||||
}
|
||||
setShowPreviewModal(false);
|
||||
setShowWebsiteInput(false);
|
||||
setWebsiteUrl("");
|
||||
}}
|
||||
onAnalyzeContent={() => {
|
||||
// Phase 2: Will trigger full website analysis
|
||||
console.log("[TopicUrlInput] Analyze Content clicked - Phase 2 feature");
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,508 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
Tabs,
|
||||
Tab,
|
||||
Chip,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
alpha,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Close as CloseIcon,
|
||||
Public as PublicIcon,
|
||||
Search as SearchIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { TrendsChart } from "../../Research/steps/components/TrendsChart";
|
||||
import { GoogleTrendsData } from "../../Research/types/intent.types";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
|
||||
interface TrendingTopicsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelectTopic: (topic: string) => void;
|
||||
initialKeywords: string;
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
|
||||
<Box role="tabpanel" hidden={value !== index} sx={{ pt: 2 }}>
|
||||
{value === index && children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const TrendingTopicsModal: React.FC<TrendingTopicsModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSelectTopic,
|
||||
initialKeywords,
|
||||
}) => {
|
||||
const [trendsData, setTrendsData] = useState<GoogleTrendsData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
const fetchTrends = useCallback(async () => {
|
||||
if (!initialKeywords.trim()) return;
|
||||
|
||||
const keywords = initialKeywords
|
||||
.split(/[,;]+/)
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 5);
|
||||
|
||||
if (keywords.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setTrendsData(null);
|
||||
|
||||
try {
|
||||
const result = await podcastApi.getTrendingTopics({
|
||||
keywords,
|
||||
timeframe: "today 12-m",
|
||||
geo: "US",
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
setTrendsData(result.data as GoogleTrendsData);
|
||||
} else {
|
||||
setError(result.error || "Failed to fetch trends data. Google may be rate-limiting requests — please try again in a few minutes.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.detail || err?.message || "Failed to fetch trending topics. Please try again later.";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [initialKeywords]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && initialKeywords.trim()) {
|
||||
fetchTrends();
|
||||
}
|
||||
}, [open, initialKeywords, fetchTrends]);
|
||||
|
||||
const handleSelectTopic = (topic: string) => {
|
||||
onSelectTopic(topic);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setTrendsData(null);
|
||||
setError(null);
|
||||
setTabValue(0);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const regions = trendsData?.interest_by_region || [];
|
||||
const relatedTopics = trendsData?.related_topics || { top: [], rising: [] };
|
||||
const relatedQueries = trendsData?.related_queries || { top: [], rising: [] };
|
||||
const hasAnyData = trendsData
|
||||
&& (
|
||||
trendsData.interest_over_time?.length > 0
|
||||
|| trendsData.interest_by_region?.length > 0
|
||||
|| trendsData.related_topics?.top?.length > 0
|
||||
|| trendsData.related_topics?.rising?.length > 0
|
||||
|| trendsData.related_queries?.top?.length > 0
|
||||
|| trendsData.related_queries?.rising?.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
maxHeight: "90vh",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
pb: 1,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 0.75,
|
||||
borderRadius: 1.5,
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<TrendingUpIcon sx={{ color: "#fff", fontSize: "1.25rem" }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: "#0f172a", fontSize: "1.1rem" }}>
|
||||
Trending Topics
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#64748b" }}>
|
||||
Google Trends insights for “{initialKeywords}”
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<IconButton onClick={handleClose} sx={{ color: "#64748b" }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ px: 3, py: 2 }}>
|
||||
{loading && (
|
||||
<Box sx={{ py: 4, textAlign: "center" }}>
|
||||
<CircularProgress size={40} sx={{ color: "#667eea", mb: 2 }} />
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
Fetching trending topics from Google Trends...
|
||||
</Typography>
|
||||
<LinearProgress sx={{ mt: 2, borderRadius: 1 }} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && trendsData && !hasAnyData && (
|
||||
<Box sx={{ py: 4, textAlign: "center" }}>
|
||||
<TrendingUpIcon sx={{ fontSize: 48, color: "#f59e0b", mb: 1 }} />
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, color: "#0f172a", mb: 1 }}>
|
||||
No trends data available
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mb: 2 }}>
|
||||
Google Trends could not find data for “{initialKeywords}”.
|
||||
{trendsData.error
|
||||
? " This may be due to rate limiting — please try again in a few minutes."
|
||||
: " The topic may be too specific. Try a broader keyword."}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={fetchTrends}
|
||||
sx={{ textTransform: "none", borderColor: "#667eea", color: "#667eea" }}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && trendsData && hasAnyData && (
|
||||
<>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={(_, v) => setTabValue(v)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{
|
||||
borderBottom: "1px solid rgba(0,0,0,0.08)",
|
||||
"& .MuiTab-root": {
|
||||
textTransform: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
"& .Mui-selected": {
|
||||
color: "#667eea",
|
||||
},
|
||||
"& .MuiTabs-indicator": {
|
||||
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab icon={<TrendingUpIcon sx={{ fontSize: "1rem" }} />} iconPosition="start" label="Interest Chart" />
|
||||
<Tab icon={<PublicIcon sx={{ fontSize: "1rem" }} />} iconPosition="start" label="Regions" />
|
||||
<Tab icon={<AutoAwesomeIcon sx={{ fontSize: "1rem" }} />} iconPosition="start" label="Related Topics" />
|
||||
<Tab icon={<SearchIcon sx={{ fontSize: "1rem" }} />} iconPosition="start" label="Related Queries" />
|
||||
</Tabs>
|
||||
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<TrendsChart data={trendsData} height={280} showAverage={true} />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
{regions.length === 0 ? (
|
||||
<Box sx={{ py: 3, textAlign: "center" }}>
|
||||
<PublicIcon sx={{ fontSize: 40, color: "#cbd5e1", mb: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No regional data available for this topic.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={1} sx={{ maxHeight: 350, overflow: "auto" }}>
|
||||
{regions.slice(0, 15).map((region: any, idx: number) => {
|
||||
const regionName = region.regionName || region.geoName || region.name || `Region ${idx + 1}`;
|
||||
const value = region.value || region.interest || 0;
|
||||
const maxVal = Math.max(...regions.slice(0, 15).map((r: any) => r.value || r.interest || 0));
|
||||
const pct = maxVal > 0 ? (value / maxVal) * 100 : 0;
|
||||
return (
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
"&:hover": { background: "rgba(102, 126, 234, 0.04)" },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ minWidth: 30, fontWeight: 600, color: "#64748b" }}>
|
||||
{idx + 1}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ flex: 1, fontWeight: 500, color: "#0f172a" }}>
|
||||
{regionName}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1, maxWidth: 200 }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: "rgba(102, 126, 234, 0.1)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: `${pct}%`,
|
||||
borderRadius: 4,
|
||||
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: "#667eea", minWidth: 30 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
{relatedTopics.top.length === 0 && relatedTopics.rising.length === 0 ? (
|
||||
<Box sx={{ py: 3, textAlign: "center" }}>
|
||||
<AutoAwesomeIcon sx={{ fontSize: 40, color: "#cbd5e1", mb: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No related topics data available.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{relatedTopics.rising.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#059669", fontWeight: 700 }}>
|
||||
Rising Topics
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{relatedTopics.rising.map((topic: any, idx: number) => {
|
||||
const label = topic.topic_title || topic.title || topic.query || String(topic);
|
||||
return (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={label}
|
||||
size="small"
|
||||
onClick={() => handleSelectTopic(label)}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)",
|
||||
color: "#059669",
|
||||
border: "1px solid rgba(16, 185, 129, 0.3)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
mb: 0.5,
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(5, 150, 105, 0.2) 100%)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
{relatedTopics.top.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#667eea", fontWeight: 700 }}>
|
||||
Top Topics
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{relatedTopics.top.map((topic: any, idx: number) => {
|
||||
const label = topic.topic_title || topic.title || topic.query || String(topic);
|
||||
return (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={label}
|
||||
size="small"
|
||||
onClick={() => handleSelectTopic(label)}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%)",
|
||||
color: "#667eea",
|
||||
border: "1px solid rgba(102, 126, 234, 0.25)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
mb: 0.5,
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.18) 0%, rgba(118, 75, 162, 0.18) 100%)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
{relatedQueries.top.length === 0 && relatedQueries.rising.length === 0 ? (
|
||||
<Box sx={{ py: 3, textAlign: "center" }}>
|
||||
<SearchIcon sx={{ fontSize: 40, color: "#cbd5e1", mb: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No related queries data available.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{relatedQueries.rising.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#059669", fontWeight: 700 }}>
|
||||
Rising Queries
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{relatedQueries.rising.map((query: any, idx: number) => {
|
||||
const label = query.query || query.title || String(query);
|
||||
return (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={label}
|
||||
size="small"
|
||||
onClick={() => handleSelectTopic(label)}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(217, 119, 6, 0.1) 100%)",
|
||||
color: "#d97706",
|
||||
border: "1px solid rgba(245, 158, 11, 0.25)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
mb: 0.5,
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, rgba(245, 158, 11, 0.18) 0%, rgba(217, 119, 6, 0.18) 100%)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
{relatedQueries.top.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#667eea", fontWeight: 700 }}>
|
||||
Top Queries
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{relatedQueries.top.map((query: any, idx: number) => {
|
||||
const label = query.query || query.title || String(query);
|
||||
return (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={label}
|
||||
size="small"
|
||||
onClick={() => handleSelectTopic(label)}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%)",
|
||||
color: "#667eea",
|
||||
border: "1px solid rgba(102, 126, 234, 0.25)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
mb: 0.5,
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.18) 0%, rgba(118, 75, 162, 0.18) 100%)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</TabPanel>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && !trendsData && (
|
||||
<Box sx={{ py: 4, textAlign: "center" }}>
|
||||
<TrendingUpIcon sx={{ fontSize: 48, color: "#cbd5e1", mb: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
Enter a topic and click “Get Trending Topics” to see Google Trends data.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: "1px solid rgba(0,0,0,0.08)",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "#94a3b8" }}>
|
||||
Data from Google Trends
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontWeight: 600,
|
||||
color: "#64748b",
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,533 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Stack,
|
||||
Divider,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Language as LanguageIcon,
|
||||
PsychologyAlt as AnalyzeIcon,
|
||||
CheckCircle as UseTextIcon,
|
||||
Close as CloseIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
const extractRootDomain = (url: string): string => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const hostname = urlObj.hostname.replace(/^www\./, '');
|
||||
return hostname;
|
||||
} catch {
|
||||
return "Website";
|
||||
}
|
||||
};
|
||||
|
||||
interface ExtractedData {
|
||||
title?: string;
|
||||
text?: string;
|
||||
summary?: string;
|
||||
highlights?: string[];
|
||||
url: string;
|
||||
image?: string;
|
||||
favicon?: string;
|
||||
subpages?: Array<{
|
||||
id?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
summary?: string;
|
||||
text?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface WebsitePreviewModalProps {
|
||||
open: boolean;
|
||||
extractedData: ExtractedData | null;
|
||||
onClose: () => void;
|
||||
onUseTextOnly: () => void;
|
||||
onAnalyzeContent: () => void;
|
||||
}
|
||||
|
||||
export const WebsitePreviewModal: React.FC<WebsitePreviewModalProps> = ({
|
||||
open,
|
||||
extractedData,
|
||||
onClose,
|
||||
onUseTextOnly,
|
||||
onAnalyzeContent,
|
||||
}) => {
|
||||
if (!extractedData) return null;
|
||||
|
||||
const rootDomain = extractRootDomain(extractedData.url);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
backgroundColor: "#ffffff",
|
||||
color: "#1e293b",
|
||||
borderRadius: 3,
|
||||
boxShadow: "0 8px 40px rgba(0, 0, 0, 0.12)",
|
||||
maxWidth: "80%",
|
||||
width: "80%",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
pb: 1,
|
||||
borderBottom: "1px solid #e2e8f0",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||
{(extractedData.favicon || extractedData.image) ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={extractedData.favicon || extractedData.image}
|
||||
alt={rootDomain}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
objectFit: "contain",
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid #e2e8f0",
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
|
||||
}}
|
||||
>
|
||||
<LanguageIcon sx={{ color: "#ffffff", fontSize: "1.25rem" }} />
|
||||
</Box>
|
||||
)}
|
||||
<Stack>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: "#0f172a",
|
||||
fontSize: "1.25rem",
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
{rootDomain} Content Analysis
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontSize: "0.8125rem",
|
||||
}}
|
||||
>
|
||||
Extracted content from your website
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
"&:hover": {
|
||||
backgroundColor: "#f1f5f9",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ pt: 3, pb: 2 }}>
|
||||
{/* Title */}
|
||||
{extractedData.title && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
color: "#667eea",
|
||||
fontWeight: 700,
|
||||
fontSize: "0.6875rem",
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Company / Organization
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: "#1e293b",
|
||||
fontWeight: 700,
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: 1.4,
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
{extractedData.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{extractedData.summary && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
color: "#667eea",
|
||||
fontWeight: 700,
|
||||
fontSize: "0.6875rem",
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
About
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
p: 2,
|
||||
backgroundColor: "#f1f5f9",
|
||||
borderRadius: 2,
|
||||
border: "1px solid #e2e8f0",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: "#334155",
|
||||
fontSize: "0.9375rem",
|
||||
lineHeight: 1.7,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{extractedData.summary.length > 800
|
||||
? extractedData.summary.substring(0, 800) + "..."
|
||||
: extractedData.summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Highlights */}
|
||||
{extractedData.highlights && extractedData.highlights.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
color: "#667eea",
|
||||
fontWeight: 700,
|
||||
fontSize: "0.6875rem",
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Key Highlights
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 1 }}>
|
||||
{extractedData.highlights.slice(0, 6).map((highlight, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 1.5,
|
||||
p: 1.5,
|
||||
backgroundColor: "#fffbeb",
|
||||
borderRadius: 1.5,
|
||||
border: "1px solid #fed7aa",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#10b981",
|
||||
mt: 0.625,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "#374151",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.6,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{highlight}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
|
||||
{/* URL */}
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1,
|
||||
backgroundColor: "#f8fafc",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<LanguageIcon sx={{ color: "#667eea", fontSize: "1rem" }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#94a3b8",
|
||||
fontSize: "0.6875rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
Source URL
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "#667eea",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 500,
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{extractedData.url}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Image / Favicon Display */}
|
||||
{(extractedData.image || extractedData.favicon) && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
color: "#667eea",
|
||||
fontWeight: 700,
|
||||
fontSize: "0.6875rem",
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Site Image
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1, display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{extractedData.favicon && (
|
||||
<Box
|
||||
component="img"
|
||||
src={extractedData.favicon}
|
||||
alt="Favicon"
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1,
|
||||
objectFit: "contain",
|
||||
backgroundColor: "#f8fafc",
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{extractedData.image && (
|
||||
<Box
|
||||
component="img"
|
||||
src={extractedData.image}
|
||||
alt="Site"
|
||||
sx={{
|
||||
maxWidth: 120,
|
||||
maxHeight: 60,
|
||||
borderRadius: 1,
|
||||
objectFit: "contain",
|
||||
backgroundColor: "#f8fafc",
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Subpages Display */}
|
||||
{extractedData.subpages && extractedData.subpages.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
color: "#667eea",
|
||||
fontWeight: 700,
|
||||
fontSize: "0.6875rem",
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Subpages ({extractedData.subpages.length})
|
||||
</Typography>
|
||||
<Stack spacing={1.5} sx={{ mt: 1 }}>
|
||||
{extractedData.subpages.slice(0, 4).map((subpage, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
backgroundColor: "#f1f5f9",
|
||||
borderRadius: 1.5,
|
||||
border: "1px solid #e2e8f0",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "#1e293b",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8125rem",
|
||||
}}
|
||||
>
|
||||
{subpage.title || subpage.url || `Page ${index + 1}`}
|
||||
</Typography>
|
||||
{subpage.summary && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontSize: "0.75rem",
|
||||
mt: 0.5,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{subpage.summary}
|
||||
</Typography>
|
||||
)}
|
||||
{subpage.url && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#667eea",
|
||||
fontSize: "0.6875rem",
|
||||
mt: 0.5,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{subpage.url}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2.5,
|
||||
borderTop: "1px solid #e2e8f0",
|
||||
gap: 1.5,
|
||||
backgroundColor: "#f8fafc",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontWeight: 600,
|
||||
color: "#64748b",
|
||||
borderColor: "#cbd5e1",
|
||||
px: 2,
|
||||
py: 1,
|
||||
"&:hover": {
|
||||
borderColor: "#94a3b8",
|
||||
backgroundColor: "#f1f5f9",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AnalyzeIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={onAnalyzeContent}
|
||||
disabled
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontWeight: 600,
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
opacity: 0.6,
|
||||
px: 2,
|
||||
py: 1,
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #7c8ff0 0%, #8a5cb3 100%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Analyze Content (Coming Soon)
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<UseTextIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={onUseTextOnly}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontWeight: 600,
|
||||
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
|
||||
boxShadow: "0 4px 12px rgba(16, 185, 129, 0.3)",
|
||||
px: 2.5,
|
||||
py: 1,
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #34d399 0%, #10b981 100%)",
|
||||
boxShadow: "0 6px 16px rgba(16, 185, 129, 0.4)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Use Text Only
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -105,7 +105,12 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
if (!audio || !blobUrl) return;
|
||||
|
||||
const updateTime = () => setCurrentTime(audio.currentTime);
|
||||
const updateDuration = () => setDuration(audio.duration);
|
||||
const updateDuration = () => {
|
||||
const d = audio.duration;
|
||||
if (d && isFinite(d) && d > 0) {
|
||||
setDuration(d);
|
||||
}
|
||||
};
|
||||
const handleEnd = () => setPlaying(false);
|
||||
const handleError = () => {
|
||||
setError('Audio playback error. Please try again.');
|
||||
@@ -114,12 +119,14 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
|
||||
audio.addEventListener("timeupdate", updateTime);
|
||||
audio.addEventListener("loadedmetadata", updateDuration);
|
||||
audio.addEventListener("durationchange", updateDuration);
|
||||
audio.addEventListener("ended", handleEnd);
|
||||
audio.addEventListener("error", handleError);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", updateTime);
|
||||
audio.removeEventListener("loadedmetadata", updateDuration);
|
||||
audio.removeEventListener("durationchange", updateDuration);
|
||||
audio.removeEventListener("ended", handleEnd);
|
||||
audio.removeEventListener("error", handleError);
|
||||
};
|
||||
@@ -239,7 +246,7 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
{effectiveAudioUrl && (
|
||||
<audio ref={audioRef} src={effectiveAudioUrl} preload="metadata" />
|
||||
<audio ref={audioRef} src={effectiveAudioUrl} preload="auto" />
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { shouldSkipOnboarding } from '../../utils/demoMode';
|
||||
import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from "@mui/material";
|
||||
import { usePodcastProjectState } from "../../hooks/usePodcastProjectState";
|
||||
import { PodcastCostEst } from "./types";
|
||||
import { CreateModal } from "./CreateModal";
|
||||
import { AnalysisPanel } from "./AnalysisPanel";
|
||||
import { ScriptEditor } from "./ScriptEditor";
|
||||
@@ -11,7 +12,6 @@ import { ProjectList } from "./ProjectList";
|
||||
import { PreflightBlockDialog } from "./PreflightBlockDialog";
|
||||
import {
|
||||
Header,
|
||||
ProgressStepper,
|
||||
EstimateCard,
|
||||
QuerySelection,
|
||||
ResearchSummary,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DEFAULT_KNOBS,
|
||||
getStepLabel,
|
||||
} from "./PodcastDashboard/index";
|
||||
import { ScriptGenerationProgressView } from "./PodcastDashboard/ScriptGenerationProgressView";
|
||||
|
||||
const PodcastDashboard: React.FC = () => {
|
||||
useEffect(() => {
|
||||
@@ -68,6 +69,50 @@ const PodcastDashboard: React.FC = () => {
|
||||
});
|
||||
|
||||
const [showRegenModal, setShowRegenModal] = useState(false);
|
||||
const headerCostEst = useMemo<PodcastCostEst | null>(() => {
|
||||
const defaultBreakdown: PodcastCostEst["breakdown"] = [
|
||||
{ phase: "Analyze", cost: 0 },
|
||||
{ phase: "Gather", cost: 0 },
|
||||
{ phase: "Write", cost: 0 },
|
||||
{ phase: "Produce", cost: 0 },
|
||||
];
|
||||
|
||||
if (!estimate && !research?.costEst) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const breakdownMap = new Map(defaultBreakdown.map((item) => [item.phase, item.cost]));
|
||||
|
||||
if (research?.costEst?.breakdown?.length) {
|
||||
research.costEst.breakdown.forEach((item) => {
|
||||
breakdownMap.set(item.phase, Number(item.cost) || 0);
|
||||
});
|
||||
}
|
||||
|
||||
if (estimate) {
|
||||
const gatherCost = breakdownMap.get("Gather") || 0;
|
||||
const produceCost = breakdownMap.get("Produce") || 0;
|
||||
if (gatherCost === 0 && estimate.researchCost > 0) {
|
||||
breakdownMap.set("Gather", estimate.researchCost);
|
||||
}
|
||||
if (produceCost === 0) {
|
||||
breakdownMap.set("Produce", estimate.ttsCost + estimate.avatarCost + estimate.videoCost);
|
||||
}
|
||||
}
|
||||
|
||||
const breakdown: PodcastCostEst["breakdown"] = defaultBreakdown.map((item) => ({
|
||||
phase: item.phase,
|
||||
cost: breakdownMap.get(item.phase) || 0,
|
||||
}));
|
||||
const total = breakdown.reduce((sum, item) => sum + item.cost, 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
breakdown,
|
||||
currency: "USD",
|
||||
last_updated: research?.costEst?.last_updated || new Date().toISOString(),
|
||||
};
|
||||
}, [estimate, research?.costEst]);
|
||||
|
||||
const handleSelectProject = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
@@ -122,6 +167,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
...(scriptData ? [2] : []),
|
||||
...(renderJobs.some(j => j.status === "completed") ? [3] : []),
|
||||
]}
|
||||
costEst={headerCostEst}
|
||||
onStepClick={(step) => {
|
||||
// Handle step clicks - could navigate to different views
|
||||
}}
|
||||
@@ -129,55 +175,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
|
||||
{/* Progress Stepper */}
|
||||
{project && workflow.activeStep >= 0 && (
|
||||
<ProgressStepper
|
||||
activeStep={workflow.activeStep}
|
||||
completedSteps={[
|
||||
...(analysis ? [0] : []), // Analysis step
|
||||
...(research ? [1] : []), // Research step
|
||||
...(scriptData ? [2] : []), // Script step
|
||||
...(scriptData && renderJobs.length > 0 ? [3] : []), // Render step (if script exists and has jobs)
|
||||
]}
|
||||
onStepClick={(stepIndex) => {
|
||||
// Navigate to the clicked step
|
||||
// Step indices: 0 = Analysis, 1 = Research, 2 = Script, 3 = Render
|
||||
if (stepIndex === 0) {
|
||||
// Navigate to Analysis
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
setCurrentStep('analysis');
|
||||
} else if (stepIndex === 1) {
|
||||
// Navigate to Research
|
||||
if (!analysis) {
|
||||
workflow.setAnnouncement("Complete Analysis first to access Research.");
|
||||
return;
|
||||
}
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
setCurrentStep('research');
|
||||
} else if (stepIndex === 2) {
|
||||
// Navigate to Script
|
||||
if (!research) {
|
||||
workflow.setAnnouncement("Complete Research first to access Script Editor.");
|
||||
return;
|
||||
}
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
setCurrentStep('script');
|
||||
} else if (stepIndex === 3) {
|
||||
// Navigate to Render
|
||||
if (!scriptData) {
|
||||
workflow.setAnnouncement("Generate and approve script first to access Render Queue.");
|
||||
return;
|
||||
}
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(true);
|
||||
setCurrentStep('render');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Progress stepper is in Header - keeping UI clean */}
|
||||
|
||||
{/* Resume Alert */}
|
||||
{workflow.showResumeAlert && project && (
|
||||
@@ -292,6 +290,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
knobs={knobsState}
|
||||
speakers={project.speakers}
|
||||
durationMinutes={project.duration}
|
||||
podcastMode={project?.podcastMode || "video_only"}
|
||||
script={scriptData}
|
||||
analysis={analysis}
|
||||
outline={analysis?.suggestedOutlines?.[0]}
|
||||
@@ -402,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,18 +1,18 @@
|
||||
import React, { useState } from "react";
|
||||
import { Stack, Typography, Box, IconButton, Menu, MenuItem, Divider, ListItemIcon, ListItemText, Collapse } from "@mui/material";
|
||||
import { Stack, Typography, Box, IconButton, Menu, MenuItem, Divider, ListItemIcon, ListItemText, Collapse, Chip, Popover, ButtonBase, useMediaQuery } from "@mui/material";
|
||||
import {
|
||||
Mic as MicIcon,
|
||||
Menu as MenuIcon,
|
||||
Close as CloseIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
AttachMoney as AttachMoneyIcon,
|
||||
LibraryMusic as LibraryMusicIcon,
|
||||
Folder as FolderIcon,
|
||||
Help as HelpIcon,
|
||||
Add as AddIcon,
|
||||
BarChart as BarChartIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PrimaryButton } from "../ui";
|
||||
import { PodcastCostEst } from "../types";
|
||||
import HeaderControls from "../../shared/HeaderControls";
|
||||
import { ProgressStepper } from "./ProgressStepper";
|
||||
|
||||
@@ -22,12 +22,22 @@ interface HeaderProps {
|
||||
activeStep?: number;
|
||||
completedSteps?: number[];
|
||||
onStepClick?: (stepIndex: number) => void;
|
||||
costEst?: PodcastCostEst | null;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, activeStep = -1, completedSteps = [], onStepClick }) => {
|
||||
const COST_PHASE_ORDER: PodcastCostEst["breakdown"][number]["phase"][] = ["Analyze", "Gather", "Write", "Produce"];
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, activeStep = -1, completedSteps = [], onStepClick, costEst }) => {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [costAnchorEl, setCostAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [isMobileCostOpen, setIsMobileCostOpen] = useState(false);
|
||||
const isMenuOpen = Boolean(anchorEl);
|
||||
const isCostOpen = Boolean(costAnchorEl);
|
||||
const costTriggerId = "podcast-cost-est-trigger";
|
||||
const costBreakdownId = "podcast-cost-est-breakdown";
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
@@ -57,6 +67,35 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
|
||||
onNewEpisode();
|
||||
};
|
||||
|
||||
const handleCostToggle = (event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!costEst) return;
|
||||
if (isMobile) {
|
||||
setIsMobileCostOpen((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
setCostAnchorEl((prev) => (prev ? null : event.currentTarget));
|
||||
};
|
||||
|
||||
const handleCostKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (!costEst) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
if (isMobile) {
|
||||
setIsMobileCostOpen((prev) => !prev);
|
||||
} else {
|
||||
setCostAnchorEl((prev) => (prev ? null : event.currentTarget));
|
||||
}
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setCostAnchorEl(null);
|
||||
setIsMobileCostOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseCostPopover = () => {
|
||||
setCostAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -118,7 +157,35 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
|
||||
</Stack>
|
||||
|
||||
{/* Right side - Hamburger Menu + HeaderControls + Create */}
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" justifyContent="flex-end">
|
||||
{costEst && (
|
||||
<ButtonBase
|
||||
id={costTriggerId}
|
||||
onClick={handleCostToggle}
|
||||
onKeyDown={handleCostKeyDown}
|
||||
aria-label={`Cost Est total ${costEst.total.toFixed(2)} dollars. ${isMobile ? "Press to expand cost breakdown." : "Press to open cost breakdown."}`}
|
||||
aria-describedby={costBreakdownId}
|
||||
aria-expanded={isMobile ? isMobileCostOpen : isCostOpen}
|
||||
sx={{ borderRadius: 999 }}
|
||||
>
|
||||
<Chip
|
||||
icon={<AttachMoneyIcon sx={{ fontSize: "0.95rem !important" }} />}
|
||||
label={`Cost Est $${costEst.total.toFixed(2)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
background: "rgba(245, 158, 11, 0.12)",
|
||||
color: "#92400e",
|
||||
fontWeight: 700,
|
||||
border: "1px solid rgba(245, 158, 11, 0.3)",
|
||||
"& .MuiChip-label": {
|
||||
px: 1.2,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ButtonBase>
|
||||
)}
|
||||
|
||||
{/* Header Controls (alerts + user) */}
|
||||
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
|
||||
|
||||
@@ -235,6 +302,74 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{costEst && (
|
||||
<>
|
||||
<Popover
|
||||
open={!isMobile && isCostOpen}
|
||||
anchorEl={costAnchorEl}
|
||||
onClose={handleCloseCostPopover}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
||||
PaperProps={{
|
||||
id: costBreakdownId,
|
||||
sx: {
|
||||
mt: 1,
|
||||
p: 1.5,
|
||||
minWidth: 220,
|
||||
borderRadius: 2,
|
||||
border: "1px solid rgba(245, 158, 11, 0.25)",
|
||||
background: "#fffbeb",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
{COST_PHASE_ORDER.map((phase) => {
|
||||
const phaseCost = costEst.breakdown.find((item) => item.phase === phase)?.cost || 0;
|
||||
return (
|
||||
<Stack key={phase} direction="row" justifyContent="space-between" gap={2}>
|
||||
<Typography variant="body2" sx={{ color: "#78350f", fontWeight: 600 }}>
|
||||
{phase}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#92400e", fontWeight: 700 }}>
|
||||
${phaseCost.toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Popover>
|
||||
|
||||
<Collapse in={isMobile && isMobileCostOpen} timeout={250}>
|
||||
<Box
|
||||
id={costBreakdownId}
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
border: "1px solid rgba(245, 158, 11, 0.2)",
|
||||
bgcolor: "#fffbeb",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={0.75}>
|
||||
{COST_PHASE_ORDER.map((phase) => {
|
||||
const phaseCost = costEst.breakdown.find((item) => item.phase === phase)?.cost || 0;
|
||||
return (
|
||||
<Stack key={phase} direction="row" justifyContent="space-between" gap={2}>
|
||||
<Typography variant="body2" sx={{ color: "#78350f", fontWeight: 600 }}>
|
||||
{phase}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#92400e", fontWeight: 700 }}>
|
||||
${phaseCost.toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Progress Stepper - integrated into header when active */}
|
||||
<Collapse in={activeStep >= 0} timeout={400}>
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
|
||||
@@ -87,17 +87,34 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const prevIsResearchingRef = useRef(isResearching);
|
||||
const modalCloseTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Close modal only when research actually completes (transitions from true to false)
|
||||
// Prevent closing while research is in progress
|
||||
useEffect(() => {
|
||||
// Clear any pending close timeout when research starts
|
||||
if (researchStarted && isResearching) {
|
||||
if (modalCloseTimeoutRef.current) {
|
||||
clearTimeout(modalCloseTimeoutRef.current);
|
||||
modalCloseTimeoutRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const wasResearching = prevIsResearchingRef.current;
|
||||
const nowNotResearching = !isResearching;
|
||||
|
||||
if (showResearchModal && researchStarted && wasResearching && nowNotResearching) {
|
||||
setTimeout(() => setShowResearchModal(false), 1000);
|
||||
modalCloseTimeoutRef.current = setTimeout(() => setShowResearchModal(false), 1000);
|
||||
}
|
||||
|
||||
prevIsResearchingRef.current = isResearching;
|
||||
|
||||
return () => {
|
||||
if (modalCloseTimeoutRef.current) {
|
||||
clearTimeout(modalCloseTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isResearching, showResearchModal, researchStarted]);
|
||||
|
||||
// Progress message cycling
|
||||
@@ -424,7 +441,13 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
{/* Research Progress Modal */}
|
||||
<Dialog
|
||||
open={showResearchModal}
|
||||
onClose={() => !isResearching && setShowResearchModal(false)}
|
||||
disableEscapeKeyDown={isResearching}
|
||||
onClose={(event, reason) => {
|
||||
// Only allow closing if NOT researching and research hasn't started
|
||||
if (!isResearching && !researchStarted) {
|
||||
setShowResearchModal(false);
|
||||
}
|
||||
}}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
fullScreen={isMobile}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { Stack, Typography, Chip, Divider, Box, alpha, Paper, Stepper, Step, StepLabel, 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,
|
||||
AttachMoney as AttachMoneyIcon,
|
||||
EditNote as EditNoteIcon,
|
||||
Article as ArticleIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
ArrowForward as ArrowForwardIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
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";
|
||||
@@ -28,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;
|
||||
@@ -56,34 +76,6 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={3}>
|
||||
{/* Step Indicator */}
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Stepper activeStep={1} alternativeLabel>
|
||||
<Step completed>
|
||||
<StepLabel
|
||||
StepIconComponent={() => <CheckCircleIcon sx={{ color: "#22c55e", fontSize: 24 }} />}
|
||||
>
|
||||
Analysis
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step active>
|
||||
<StepLabel>
|
||||
Research
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step>
|
||||
<StepLabel>
|
||||
Script
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step>
|
||||
<StepLabel>
|
||||
Render
|
||||
</StepLabel>
|
||||
</Step>
|
||||
</Stepper>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
@@ -130,19 +122,6 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{research.costEst?.total !== undefined && (
|
||||
<Chip
|
||||
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||
label={`$${research.costEst.total.toFixed(3)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#f59e0b", 0.1),
|
||||
color: "#d97706",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(245, 158, 11, 0.2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -193,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={{
|
||||
@@ -230,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
|
||||
{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)
|
||||
bgcolor: alpha("#667eea", 0.05),
|
||||
cursor: source?.url ? 'pointer' : 'default',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -299,6 +325,167 @@ 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 }}>
|
||||
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}>
|
||||
{research.expertQuotes.slice(0, 4).map((quote, idx) => {
|
||||
const sourceUrl = research.sources?.[quote.source_index - 1]?.url;
|
||||
return (
|
||||
<Paper key={`${quote.source_index}-${idx}`} elevation={0} sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #F5F3FF 0%, #EDE9FE 100%)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.15)'
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ color: "#1E1B4B", lineHeight: 1.65, fontStyle: 'italic', fontWeight: 500 }}>
|
||||
"{quote.quote}"
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<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"
|
||||
component="a"
|
||||
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: sourceUrl ? 'pointer' : 'default',
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #7C3AED 0%, #6D28D9 100%)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No expert quotes extracted yet.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Listener CTAs */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
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}>
|
||||
{research.listenerCta.slice(0, 4).map((cta, idx) => (
|
||||
<Paper key={`cta-${idx}`} elevation={0} sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #ECFDF5 0%, #D1FAE5 100%)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.15)'
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ color: "#064E3B", lineHeight: 1.65, fontWeight: 500 }}>
|
||||
{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, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
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}>
|
||||
{research.mappedAngles.slice(0, 4).map((angle, idx) => (
|
||||
<Paper key={`angle-${idx}`} elevation={0} sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #F0F9FF 0%, #E0F2FE 100%)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.15)'
|
||||
}}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0C4A6E", fontWeight: 700, mb: 0.5 }}>
|
||||
{angle.title || `Angle ${idx + 1}`}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#075985", lineHeight: 1.65 }}>
|
||||
{angle.why || "No rationale provided."}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
No mapped angles available yet.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Search Queries Used */}
|
||||
{research.searchQueries && research.searchQueries.length > 0 && (
|
||||
<Box sx={{ mt: 4, pt: 3, borderTop: "1px solid rgba(0,0,0,0.04)" }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -62,6 +62,11 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
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({
|
||||
onBlocked: (response) => {
|
||||
@@ -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);
|
||||
@@ -354,7 +392,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
try {
|
||||
console.log('[Research] Starting research with:', { topic: project.idea, approvedQueries, provider: researchProvider });
|
||||
console.log('[Research] Calling podcastApi.runResearch...');
|
||||
const { research: mapped, raw } = await podcastApi.runResearch({
|
||||
const { research: mapped, raw, estimate } = await podcastApi.runResearch({
|
||||
projectId: project.id,
|
||||
topic: project.idea,
|
||||
approvedQueries,
|
||||
@@ -369,6 +407,9 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
console.log('[Research] Response received:', { mapped, raw });
|
||||
setResearch(mapped);
|
||||
setRawResearch(raw);
|
||||
if (estimate) {
|
||||
setEstimate(estimate);
|
||||
}
|
||||
setAnnouncement("Research complete — review fact cards below");
|
||||
} catch (researchError) {
|
||||
const errorMessage = researchError instanceof Error
|
||||
@@ -392,45 +433,44 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
} finally {
|
||||
setIsResearching(false);
|
||||
}
|
||||
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, 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);
|
||||
|
||||
setPreflightOperationName("Script Generation");
|
||||
const preflightResult = await preflightCheck.check({
|
||||
provider: "gemini",
|
||||
operation_type: "script_generation",
|
||||
tokens_requested: 2000,
|
||||
actual_provider_name: "gemini",
|
||||
});
|
||||
// Show modal IMMEDIATELY to prevent duplicate clicks
|
||||
setShowScriptGenModal(true);
|
||||
setScriptGenStarted(true);
|
||||
setScriptGenProgressIndex(0);
|
||||
console.log('[ScriptGen] Modal shown, generating ref set');
|
||||
|
||||
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 {
|
||||
@@ -449,6 +489,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
knobs: projectState.knobs,
|
||||
speakers: project.speakers,
|
||||
durationMinutes: project.duration,
|
||||
podcastMode: (project as any)?.podcastMode || "video_only",
|
||||
bible: projectState.bible,
|
||||
outline: analysis?.suggestedOutlines?.[0],
|
||||
analysis: analysis,
|
||||
@@ -460,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) {
|
||||
@@ -468,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
|
||||
@@ -604,6 +646,11 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
duplicateProjectInfo,
|
||||
activeStep,
|
||||
canGenerateScript,
|
||||
// Script Generation Modal
|
||||
showScriptGenModal,
|
||||
setShowScriptGenModal,
|
||||
scriptGenStarted,
|
||||
scriptGenProgressIndex,
|
||||
// Handlers
|
||||
handleCreate,
|
||||
handleRegenerate,
|
||||
@@ -625,4 +672,3 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
handleDeleteQuery,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ export 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,
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "@mui/icons-material";
|
||||
import { Scene, Job } from "../types";
|
||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||
import { Typography } from "@mui/material"; // Import Typography
|
||||
import { Typography } from "@mui/material";
|
||||
import { OperationButton } from "../../shared/OperationButton";
|
||||
|
||||
interface SceneActionButtonsProps {
|
||||
scene: Scene;
|
||||
@@ -94,14 +95,37 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
>
|
||||
Preview Sample
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: "audio",
|
||||
model: "minimax/speech-02-hd",
|
||||
tokens_requested: scene.lines.reduce((sum, l) => sum + l.text.length, 0),
|
||||
operation_type: "tts_full_render",
|
||||
actual_provider_name: "wavespeed",
|
||||
}}
|
||||
label="Generate Audio"
|
||||
variant="contained"
|
||||
size="medium"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
showCost={true}
|
||||
checkOnHover={true}
|
||||
checkOnMount={false}
|
||||
onClick={() => onRender(scene.id, "full")}
|
||||
disabled={isBusy}
|
||||
startIcon={<PlayArrowIcon />}
|
||||
tooltip="Generate the complete, production-ready audio for this scene"
|
||||
>
|
||||
Generate Audio
|
||||
</PrimaryButton>
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: alpha("#9ca3af", 0.3),
|
||||
color: alpha("#fff", 0.5),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -221,58 +245,77 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
</Tooltip>
|
||||
|
||||
{/* Generate/Regenerate Image - ALWAYS visible if we have audio */}
|
||||
<PrimaryButton
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: "stability",
|
||||
operation_type: "image_generation",
|
||||
actual_provider_name: "wavespeed",
|
||||
}}
|
||||
label={isGeneratingImage ? "Generating..." : hasImage ? "Regenerate Image" : "Generate Image"}
|
||||
variant="contained"
|
||||
size="medium"
|
||||
startIcon={<ImageIcon />}
|
||||
showCost={true}
|
||||
checkOnHover={true}
|
||||
checkOnMount={false}
|
||||
onClick={() => onImageGenerate(scene.id)}
|
||||
disabled={isGeneratingImage}
|
||||
loading={isGeneratingImage}
|
||||
startIcon={<ImageIcon />}
|
||||
tooltip={
|
||||
isGeneratingImage
|
||||
? "Generating image..."
|
||||
: hasImage
|
||||
? "Regenerate image for this scene"
|
||||
: "Generate image for video (optional)"
|
||||
}
|
||||
sx={{
|
||||
minWidth: 160,
|
||||
// Use secondary style if image exists (to de-emphasize), primary if needed
|
||||
background: hasImage ? alpha("#667eea", 0.1) : undefined,
|
||||
color: hasImage ? "#667eea" : undefined,
|
||||
background: hasImage ? alpha("#667eea", 0.1) : "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: hasImage ? "#667eea" : "white",
|
||||
border: hasImage ? "1px solid rgba(102,126,234,0.3)" : undefined,
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
"&:hover": {
|
||||
background: hasImage ? alpha("#667eea", 0.2) : undefined,
|
||||
}
|
||||
background: hasImage ? alpha("#667eea", 0.2) : "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: alpha("#9ca3af", 0.3),
|
||||
color: alpha("#fff", 0.5),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isGeneratingImage ? "Generating..." : hasImage ? "Regenerate Image" : "Generate Image"}
|
||||
</PrimaryButton>
|
||||
/>
|
||||
|
||||
{/* Generate Video - ALWAYS visible if we have audio */}
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
onVideoRender(scene.id);
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: "video",
|
||||
model: "kling-v2.5-turbo-5s",
|
||||
operation_type: "video_generation",
|
||||
actual_provider_name: "wavespeed",
|
||||
}}
|
||||
disabled={isBusy || videoInProgress || !hasImage}
|
||||
startIcon={<VideocamIcon />}
|
||||
tooltip={
|
||||
!hasImage
|
||||
? "Generate an image first to create video"
|
||||
: videoInProgress
|
||||
? "A video generation is already running. Please wait..."
|
||||
: isBusy
|
||||
? "Another operation in progress"
|
||||
: hasVideo
|
||||
? "Regenerate video"
|
||||
: "Generate video for this scene"
|
||||
}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
{videoInProgress && isCurrentVideo
|
||||
label={
|
||||
videoInProgress && isCurrentVideo
|
||||
? "Generating Video..."
|
||||
: hasVideo
|
||||
? "Regenerate Video"
|
||||
: "Generate Video"}
|
||||
</PrimaryButton>
|
||||
: "Generate Video"
|
||||
}
|
||||
variant="contained"
|
||||
size="medium"
|
||||
startIcon={<VideocamIcon />}
|
||||
showCost={true}
|
||||
checkOnHover={true}
|
||||
checkOnMount={false}
|
||||
onClick={() => onVideoRender(scene.id)}
|
||||
disabled={isBusy || videoInProgress || !hasImage}
|
||||
sx={{
|
||||
minWidth: 180,
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: alpha("#9ca3af", 0.3),
|
||||
color: alpha("#fff", 0.5),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Download Video */}
|
||||
{hasVideo && job?.videoUrl && (
|
||||
|
||||
@@ -140,7 +140,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
// Check cache first with scene context
|
||||
const cachedUrl = getCachedMedia(imageUrl, scene.id);
|
||||
if (cachedUrl) {
|
||||
console.log('[SceneCard] Using cached image:', imageUrl, `(scene: ${scene.id})`);
|
||||
if (process.env.NODE_ENV === 'development') console.log('[SceneCard] Using cached image:', imageUrl, `(scene: ${scene.id})`);
|
||||
setImageBlobUrl(cachedUrl);
|
||||
setImageLoading(false);
|
||||
setImageError(null);
|
||||
@@ -167,7 +167,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
try {
|
||||
setImageLoading(true);
|
||||
setImageError(null);
|
||||
console.log('[SceneCard] Loading image blob for:', currentImageUrl);
|
||||
if (process.env.NODE_ENV === 'development') console.log('[SceneCard] Loading image blob for:', currentImageUrl.split('?')[0]);
|
||||
|
||||
// Check cache again in case it was loaded while we were waiting
|
||||
const cachedUrl = getCachedMedia(currentImageUrl, scene.id);
|
||||
@@ -219,7 +219,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
}
|
||||
return newBlobUrl;
|
||||
});
|
||||
console.log('[SceneCard] Image blob loaded and cached successfully:', currentImageUrl);
|
||||
if (process.env.NODE_ENV === 'development') console.log('[SceneCard] Image blob loaded and cached successfully:', currentImageUrl.split('?')[0]);
|
||||
} catch (err) {
|
||||
console.error('[SceneCard] Failed to load image blob:', err);
|
||||
if (isMounted && imageUrl === currentImageUrl) {
|
||||
@@ -287,7 +287,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
// Check cache first with scene context
|
||||
const cachedUrl = getCachedMedia(job.videoUrl, scene.id);
|
||||
if (cachedUrl) {
|
||||
console.log('[SceneCard] Using cached video:', job.videoUrl, `(scene: ${scene.id})`);
|
||||
if (process.env.NODE_ENV === 'development') console.log('[SceneCard] Using cached video:', job.videoUrl?.split('?')[0], `(scene: ${scene.id})`);
|
||||
setVideoBlobUrl(cachedUrl);
|
||||
setVideoLoading(false);
|
||||
setVideoError(null);
|
||||
@@ -312,7 +312,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SceneCard] Loading video blob for:', job.videoUrl);
|
||||
if (process.env.NODE_ENV === 'development') console.log('[SceneCard] Loading video blob for:', (job.videoUrl || '').split('?')[0]);
|
||||
const blobUrl = await fetchMediaBlobUrl(job.videoUrl!);
|
||||
|
||||
if (blobUrl) {
|
||||
@@ -332,7 +332,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
|
||||
testVideo.onloadedmetadata = () => {
|
||||
clearTimeout(timeout);
|
||||
console.log('[SceneCard] Video blob validation successful:', {
|
||||
if (process.env.NODE_ENV === 'development') console.log('[SceneCard] Video blob validation successful:', {
|
||||
duration: testVideo.duration,
|
||||
videoWidth: testVideo.videoWidth,
|
||||
videoHeight: testVideo.videoHeight,
|
||||
@@ -353,7 +353,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
// Cache the validated blob URL with scene context
|
||||
setCachedMedia(job.videoUrl!, blobUrl, 'video', undefined, scene.id);
|
||||
|
||||
console.log('[SceneCard] Video blob loaded, validated, and cached successfully:', job.videoUrl);
|
||||
if (process.env.NODE_ENV === 'development') console.log('[SceneCard] Video blob loaded, validated, and cached successfully:', (job.videoUrl || '').split('?')[0]);
|
||||
} else {
|
||||
// Direct URL fallback
|
||||
setVideoBlobUrl(blobUrl);
|
||||
|
||||
@@ -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;
|
||||
@@ -132,7 +132,7 @@ export const useRenderQueue = ({
|
||||
|
||||
// Skip if job already has imageUrl from script phase - don't override with old video
|
||||
if (job?.imageUrl) {
|
||||
console.log("[useRenderQueue] Skipping old video - job has imageUrl from script phase:", scene.id, "imageUrl:", job.imageUrl);
|
||||
if (process.env.NODE_ENV === 'development') console.log("[useRenderQueue] Skipping old video - job has imageUrl from script phase:", scene.id, "imageUrl:", (job.imageUrl || '').split('?')[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export const useRenderQueue = ({
|
||||
// If job has finalUrl (audio) or imageUrl from script phase, don't attach old video
|
||||
const isJobEmpty = !job || (!job.imageUrl && !job.videoUrl && !job.finalUrl);
|
||||
if (!isJobEmpty) {
|
||||
console.log("[useRenderQueue] Skipping old video - job has content already:", scene.id, "job:", job);
|
||||
if (process.env.NODE_ENV === 'development') console.log("[useRenderQueue] Skipping old video - job has content already:", scene.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -576,15 +581,10 @@ export const useRenderQueue = ({
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("[useRenderQueue] Starting video generation", {
|
||||
if (process.env.NODE_ENV === 'development') console.log("[useRenderQueue] Starting video generation", {
|
||||
sceneId,
|
||||
sceneTitle: scene.title,
|
||||
audioUrl,
|
||||
avatarImageUrl: sceneImageUrl,
|
||||
resolution: targetResolution,
|
||||
prompt: settings?.prompt,
|
||||
seed: settings?.seed,
|
||||
maskImageUrl: settings?.maskImageUrl,
|
||||
});
|
||||
|
||||
const result = await podcastApi.generateVideo({
|
||||
@@ -703,7 +703,7 @@ export const useRenderQueue = ({
|
||||
sceneVideoUrls.push(videoUrl);
|
||||
}
|
||||
|
||||
console.log("[combineFinalVideo] Starting combination with", sceneVideoUrls.length, "videos");
|
||||
if (process.env.NODE_ENV === 'development') console.log("[combineFinalVideo] Starting combination with", sceneVideoUrls.length, "videos");
|
||||
|
||||
// Start combination task
|
||||
const result = await podcastApi.combineVideos({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,13 +10,18 @@ import {
|
||||
Delete as DeleteIcon,
|
||||
Fullscreen as FullscreenIcon,
|
||||
Close as CloseIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Info as InfoIcon,
|
||||
Mic as MicIcon,
|
||||
HelpOutline as HelpOutlineIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Scene, Line, Knobs } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { OperationButton } from "../../shared/OperationButton";
|
||||
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";
|
||||
|
||||
@@ -33,6 +38,7 @@ interface SceneEditorProps {
|
||||
idea?: string; // Podcast idea for image generation context
|
||||
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
|
||||
totalScenes?: number; // Total number of scenes in the script
|
||||
sceneIndex?: number; // Current scene index (0-based) for 1/N numbering
|
||||
analysis?: {
|
||||
audience?: string;
|
||||
contentType?: string;
|
||||
@@ -53,6 +59,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
idea,
|
||||
avatarUrl,
|
||||
totalScenes,
|
||||
sceneIndex,
|
||||
analysis,
|
||||
}) => {
|
||||
const [localGenerating, setLocalGenerating] = useState(false);
|
||||
@@ -65,9 +72,14 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
|
||||
const [showAudioModal, setShowAudioModal] = useState(false);
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
const [showApprovalInfo, setShowApprovalInfo] = useState(false);
|
||||
const [showWhyScript, setShowWhyScript] = useState(false);
|
||||
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,
|
||||
@@ -281,6 +293,15 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
const hasAudio = Boolean(scene.audioUrl && audioBlobUrl);
|
||||
const hasImage = Boolean(scene.imageUrl);
|
||||
|
||||
// Completion status for visual feedback
|
||||
const isComplete = hasAudio && hasImage;
|
||||
const completionPercent = (hasAudio ? 50 : 0) + (hasImage ? 50 : 0);
|
||||
|
||||
// Scene order for 1/N badge display
|
||||
const sceneOrder = sceneIndex != null ? sceneIndex + 1 : null;
|
||||
const totalScenesInline = totalScenes ?? null;
|
||||
const showOrderBadge = sceneOrder != null && totalScenesInline != null;
|
||||
|
||||
const handleApproveAndGenerate = async (settings?: AudioGenerationSettings) => {
|
||||
const wasAlreadyApproved = scene.approved;
|
||||
const sceneId = scene.id;
|
||||
@@ -308,10 +329,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,
|
||||
@@ -500,124 +525,402 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={2.5}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<GlassyCard
|
||||
sx={{
|
||||
...glassyCardSx,
|
||||
border: isComplete
|
||||
? "2px solid #10b981"
|
||||
: completionPercent > 0
|
||||
? "2px solid #f59e0b"
|
||||
: "1px solid rgba(15, 23, 42, 0.08)",
|
||||
background: isComplete
|
||||
? "linear-gradient(135deg, rgba(16, 185, 129, 0.03) 0%, rgba(255, 255, 255, 0.9) 100%)"
|
||||
: completionPercent > 0
|
||||
? "linear-gradient(135deg, rgba(245, 158, 11, 0.03) 0%, rgba(255, 255, 255, 0.9) 100%)"
|
||||
: glassyCardSx.background,
|
||||
boxShadow: isComplete
|
||||
? "0 4px 20px rgba(16, 185, 129, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)"
|
||||
: completionPercent > 0
|
||||
? "0 4px 20px rgba(245, 158, 11, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)"
|
||||
: glassyCardSx.boxShadow,
|
||||
}}
|
||||
>
|
||||
{/* Completion Progress Bar */}
|
||||
<Box sx={{ position: "relative", height: 4, mb: 2, borderRadius: 2, overflow: "hidden", backgroundColor: "rgba(0,0,0,0.04)" }}>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: `${completionPercent}%`,
|
||||
background: isComplete
|
||||
? "linear-gradient(90deg, #10b981 0%, #059669 100%)"
|
||||
: "linear-gradient(90deg, #f59e0b 0%, #d97706 100%)",
|
||||
transition: "width 0.5s ease",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={{ xs: 2, sm: 2.5 }}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'stretch', sm: 'flex-start' }}
|
||||
spacing={{ xs: 2, sm: 0 }}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
mb: 1,
|
||||
gap: { xs: 1, sm: 1.5 },
|
||||
mb: { xs: 0.75, sm: 1 },
|
||||
color: "#0f172a",
|
||||
fontWeight: 600,
|
||||
fontSize: "1.25rem",
|
||||
fontSize: { xs: "1.1rem", sm: "1.25rem" },
|
||||
letterSpacing: "-0.01em",
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<EditNoteIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1.5rem" }} />
|
||||
<EditNoteIcon fontSize="small" sx={{ color: isComplete ? "#059669" : completionPercent > 0 ? "#d97706" : "#667eea", fontSize: { xs: "1.25rem", sm: "1.5rem" } }} />
|
||||
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: { xs: 'normal', sm: 'nowrap' } }}>
|
||||
{scene.title}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
|
||||
</Box>
|
||||
{showOrderBadge && (
|
||||
<Chip
|
||||
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
|
||||
label={scene.approved ? "Approved" : "Pending Approval"}
|
||||
label={`${sceneOrder}/${totalScenesInline}`}
|
||||
size="small"
|
||||
color={scene.approved ? "success" : "warning"}
|
||||
sx={{
|
||||
background: scene.approved
|
||||
? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)"
|
||||
: "linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(217, 119, 6, 0.12) 100%)",
|
||||
color: scene.approved ? "#059669" : "#d97706",
|
||||
border: scene.approved
|
||||
? "1px solid rgba(16, 185, 129, 0.25)"
|
||||
: "1px solid rgba(245, 158, 11, 0.25)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
|
||||
ml: { xs: 0, sm: 0.5 },
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
height: { xs: 20, sm: 24 },
|
||||
fontSize: { xs: '0.65rem', sm: '0.7rem' },
|
||||
fontWeight: 700,
|
||||
color: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.3)',
|
||||
'& .MuiChip-label': {
|
||||
px: { xs: 0.75, sm: 1 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem" }}>
|
||||
)}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={{ xs: 1, sm: 1.5 }} alignItems="center" flexWrap="wrap">
|
||||
{/* Completion Status Chip */}
|
||||
<Chip
|
||||
icon={isComplete ? <CheckCircleIcon /> : completionPercent > 0 ? <RefreshIcon /> : <RadioButtonUncheckedIcon />}
|
||||
label={isComplete ? "Complete" : completionPercent > 0 ? `In Progress ${completionPercent}%` : "Pending"}
|
||||
size="small"
|
||||
sx={{
|
||||
background: isComplete
|
||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||
: completionPercent > 0
|
||||
? "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)"
|
||||
: "linear-gradient(135deg, #64748b 0%, #475569 100%)",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
fontWeight: 700,
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
borderRadius: "12px",
|
||||
px: 1,
|
||||
boxShadow: isComplete
|
||||
? "0 3px 12px rgba(16, 185, 129, 0.4)"
|
||||
: completionPercent > 0
|
||||
? "0 3px 12px rgba(245, 158, 11, 0.4)"
|
||||
: "0 2px 6px rgba(100, 116, 139, 0.3)",
|
||||
'& .MuiChip-icon': {
|
||||
fontSize: '1rem',
|
||||
color: '#ffffff',
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
pl: 0.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Audio Status */}
|
||||
<Chip
|
||||
icon={hasAudio ? <CheckCircleIcon sx={{ fontSize: '0.875rem !important' }} /> : <VolumeUpIcon sx={{ fontSize: '0.875rem !important' }} />}
|
||||
label={hasAudio ? "Audio Ready" : "No Audio"}
|
||||
size="small"
|
||||
sx={{
|
||||
background: hasAudio
|
||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||
: "linear-gradient(135deg, #94a3b8 0%, #64748b 100%)",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.7rem",
|
||||
height: 22,
|
||||
borderRadius: "10px",
|
||||
px: 0.75,
|
||||
boxShadow: hasAudio ? "0 2px 8px rgba(16, 185, 129, 0.35)" : "0 1px 4px rgba(100, 116, 139, 0.25)",
|
||||
'& .MuiChip-icon': {
|
||||
color: '#ffffff',
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
pl: 0.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Image Status */}
|
||||
<Chip
|
||||
icon={hasImage ? <CheckCircleIcon sx={{ fontSize: '0.875rem !important' }} /> : <ImageIcon sx={{ fontSize: '0.875rem !important' }} />}
|
||||
label={hasImage ? "Image Ready" : "No Image"}
|
||||
size="small"
|
||||
sx={{
|
||||
background: hasImage
|
||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||
: "linear-gradient(135deg, #94a3b8 0%, #64748b 100%)",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.7rem",
|
||||
height: 22,
|
||||
borderRadius: "10px",
|
||||
px: 0.75,
|
||||
boxShadow: hasImage ? "0 2px 8px rgba(16, 185, 129, 0.35)" : "0 1px 4px rgba(100, 116, 139, 0.25)",
|
||||
'& .MuiChip-icon': {
|
||||
color: '#ffffff',
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
pl: 0.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem", ml: 1 }}>
|
||||
Duration: {scene.duration}s
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Approval Info Panel - Inline chips for guidance */}
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" sx={{ mt: 1 }}>
|
||||
{/* Active Voice indicator */}
|
||||
<Chip
|
||||
icon={<MicIcon sx={{ fontSize: '0.875rem !important' }} />}
|
||||
label={`Voice: ${knobs.voice_id === "Wise_Woman" ? "Wise Woman" : knobs.voice_id?.replace(/_/g, " ") || "Default"}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.7rem",
|
||||
height: 22,
|
||||
borderRadius: "10px",
|
||||
px: 0.75,
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
|
||||
'& .MuiChip-icon': { color: '#ffffff' },
|
||||
'& .MuiChip-label': {
|
||||
pl: 0.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Why Script chip - opens modal with guidance */}
|
||||
<Chip
|
||||
icon={<HelpOutlineIcon sx={{ fontSize: '0.875rem !important' }} />}
|
||||
label="Why Script?"
|
||||
size="small"
|
||||
onClick={() => setShowWhyScript(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowWhyScript(true);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label="Learn why scene approval is required"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.7rem",
|
||||
height: 22,
|
||||
borderRadius: "10px",
|
||||
px: 0.75,
|
||||
cursor: "pointer",
|
||||
boxShadow: "0 2px 8px rgba(245, 158, 11, 0.35)",
|
||||
'& .MuiChip-icon': { color: '#ffffff' },
|
||||
'& .MuiChip-label': {
|
||||
pl: 0.5,
|
||||
},
|
||||
'&:hover': {
|
||||
background: "linear-gradient(135deg, #d97706 0%, #b45309 100%)",
|
||||
boxShadow: "0 3px 10px rgba(245, 158, 11, 0.45)",
|
||||
},
|
||||
'&:focus': {
|
||||
outline: '2px solid rgba(245, 158, 11, 0.6)',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
|
||||
<PrimaryButton
|
||||
onClick={handleAudioRegenerateClick}
|
||||
disabled={approving || generating}
|
||||
loading={approving || generating}
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={{ xs: 1, sm: 1.5 }}
|
||||
flexWrap="wrap"
|
||||
useFlexGap
|
||||
sx={{
|
||||
width: { xs: '100%', sm: 'auto' },
|
||||
'& > *': { width: { xs: '100%', sm: 'auto' } }
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
title={
|
||||
hasAudio && !generating
|
||||
? "✓ Audio generated! Click to regenerate with different settings"
|
||||
: generating
|
||||
? "Generating audio... please wait"
|
||||
: scene.approved
|
||||
? "Generate audio for this scene"
|
||||
: "Approve scene and generate audio"
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: "audio",
|
||||
model: "minimax/speech-02-hd",
|
||||
tokens_requested: scene.lines.reduce((sum, l) => sum + l.text.length, 0),
|
||||
operation_type: "tts_full_render",
|
||||
actual_provider_name: "wavespeed",
|
||||
}}
|
||||
label={
|
||||
hasAudio && !generating
|
||||
? "✓ Regenerate Audio"
|
||||
: generating
|
||||
? "Generating Audio..."
|
||||
: scene.approved
|
||||
? "Generate Audio"
|
||||
: "Approve & Generate Audio"
|
||||
}
|
||||
variant="contained"
|
||||
size="medium"
|
||||
startIcon={
|
||||
hasAudio && !generating ? (
|
||||
<VolumeUpIcon />
|
||||
<RefreshIcon />
|
||||
) : generating ? (
|
||||
<CircularProgress size={16} sx={{ color: "white" }} />
|
||||
) : (
|
||||
<PlayArrowIcon />
|
||||
)
|
||||
}
|
||||
tooltip={
|
||||
hasAudio && !generating
|
||||
? "Regenerate audio for this scene with custom settings"
|
||||
: generating
|
||||
? "Generating audio..."
|
||||
: scene.approved
|
||||
? "Generate audio for this scene"
|
||||
: "Approve scene and generate audio"
|
||||
}
|
||||
showCost={true}
|
||||
checkOnHover={true}
|
||||
checkOnMount={false}
|
||||
onClick={handleAudioRegenerateClick}
|
||||
disabled={approving || generating}
|
||||
loading={approving || generating}
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
minWidth: { xs: '100%', sm: 200 },
|
||||
background: hasAudio
|
||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
fontSize: { xs: '0.8rem', sm: '0.875rem' },
|
||||
py: { xs: 0.75, sm: 1 },
|
||||
boxShadow: hasAudio
|
||||
? "0 4px 14px rgba(16, 185, 129, 0.35)"
|
||||
: "0 4px 14px rgba(102, 126, 234, 0.35)",
|
||||
"&:hover": {
|
||||
background: hasAudio
|
||||
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
|
||||
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
boxShadow: hasAudio
|
||||
? "0 6px 20px rgba(16, 185, 129, 0.45)"
|
||||
: "0 6px 20px rgba(102, 126, 234, 0.45)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: alpha("#9ca3af", 0.3),
|
||||
color: alpha("#fff", 0.5),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
hasImage && !generatingImage
|
||||
? "✓ Image generated! Click to regenerate with different settings"
|
||||
: generatingImage
|
||||
? "Generating image... please wait"
|
||||
: "Generate image for video (optional but recommended)"
|
||||
}
|
||||
>
|
||||
{hasAudio && !generating
|
||||
? "Regenerate Audio"
|
||||
: generating
|
||||
? "Generating Audio..."
|
||||
: scene.approved
|
||||
? "Generate Audio"
|
||||
: "Approve & Generate Audio"}
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
onClick={hasImage ? handleRegenerateClick : () => handleGenerateImage()}
|
||||
disabled={generatingImage}
|
||||
loading={generatingImage}
|
||||
<Box>
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: "stability",
|
||||
operation_type: "image_generation",
|
||||
actual_provider_name: "wavespeed",
|
||||
}}
|
||||
label={
|
||||
hasImage && !generatingImage
|
||||
? "✓ Regenerate Image"
|
||||
: generatingImage
|
||||
? "Generating Image..."
|
||||
: "Generate Image"
|
||||
}
|
||||
variant="contained"
|
||||
size="medium"
|
||||
startIcon={
|
||||
hasImage && !generatingImage ? (
|
||||
<ImageIcon />
|
||||
<RefreshIcon />
|
||||
) : generatingImage ? (
|
||||
<CircularProgress size={16} sx={{ color: "white" }} />
|
||||
) : (
|
||||
<ImageIcon />
|
||||
)
|
||||
}
|
||||
tooltip={
|
||||
hasImage
|
||||
? "Regenerate image for this scene"
|
||||
: generatingImage
|
||||
? "Generating image..."
|
||||
: "Generate image for video (optional)"
|
||||
}
|
||||
showCost={true}
|
||||
checkOnHover={true}
|
||||
checkOnMount={false}
|
||||
onClick={hasImage ? handleRegenerateClick : () => handleGenerateImage()}
|
||||
disabled={generatingImage}
|
||||
loading={generatingImage}
|
||||
sx={{
|
||||
minWidth: 180,
|
||||
minWidth: { xs: '100%', sm: 180 },
|
||||
background: hasImage
|
||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
fontSize: { xs: '0.8rem', sm: '0.875rem' },
|
||||
py: { xs: 0.75, sm: 1 },
|
||||
boxShadow: hasImage
|
||||
? "0 4px 14px rgba(16, 185, 129, 0.35)"
|
||||
: "0 4px 14px rgba(102, 126, 234, 0.35)",
|
||||
"&:hover": {
|
||||
background: hasImage
|
||||
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
|
||||
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
boxShadow: hasImage
|
||||
? "0 6px 20px rgba(16, 185, 129, 0.45)"
|
||||
: "0 6px 20px rgba(102, 126, 234, 0.45)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: alpha("#9ca3af", 0.3),
|
||||
color: alpha("#fff", 0.5),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{hasImage && !generatingImage
|
||||
? "Regenerate Image"
|
||||
: generatingImage
|
||||
? "Generating Image..."
|
||||
: "Generate Image"}
|
||||
</PrimaryButton>
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={totalScenes && totalScenes <= 1 ? "Cannot delete the last scene" : "Delete this scene"}>
|
||||
<IconButton
|
||||
@@ -628,7 +931,8 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.2)",
|
||||
borderRadius: 2,
|
||||
padding: 1.5,
|
||||
padding: { xs: 1, sm: 1.5 },
|
||||
alignSelf: { xs: 'center', sm: 'auto' },
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(239, 68, 68, 0.15)",
|
||||
borderColor: "rgba(239, 68, 68, 0.3)",
|
||||
@@ -640,7 +944,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: "1.25rem" }} />
|
||||
<DeleteIcon sx={{ fontSize: { xs: "1.1rem", sm: "1.25rem" } }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
@@ -896,6 +1200,70 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Why Script Modal - Guidance for scene approval */}
|
||||
<Dialog
|
||||
open={showWhyScript}
|
||||
onClose={() => setShowWhyScript(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
p: 2,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<HelpOutlineIcon sx={{ color: '#d97706', fontSize: '1.5rem' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Why Approve This Scene?
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Typography variant="body2" sx={{ color: '#475569', lineHeight: 1.7 }}>
|
||||
Each scene requires <strong>approval</strong> before audio can be generated. Here's why the approval process matters:
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
background: 'rgba(245, 158, 11, 0.08)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
}}>
|
||||
<Stack spacing={1.5}>
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
<CheckCircleIcon sx={{ color: '#10b981', fontSize: '1.25rem' }} />
|
||||
<Typography variant="body2" sx={{ color: '#059669', flex: 1 }}>
|
||||
<strong>Script Accuracy:</strong> The AI generates audio based on the script text. Once approved, the text is locked to ensure consistency.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
<CheckCircleIcon sx={{ color: '#10b981', fontSize: '1.25rem' }} />
|
||||
<Typography variant="body2" sx={{ color: '#059669', flex: 1 }}>
|
||||
<strong>Cost Control:</strong> Audio generation uses your subscription credits. Approving ensures you only pay for scenes you intend to render.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
<CheckCircleIcon sx={{ color: '#10b981', fontSize: '1.25rem' }} />
|
||||
<Typography variant="body2" sx={{ color: '#059669', flex: 1 }}>
|
||||
<strong>Quality Check:</strong> Review your script for tone, pacing, and accuracy before spending credits on audio generation.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
|
||||
Pro tip: You can always regenerate audio later with different voice settings after approval.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<PrimaryButton onClick={() => setShowWhyScript(false)}>
|
||||
Got it
|
||||
</PrimaryButton>
|
||||
</Box>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, Collapse, IconButton, Divider, Chip, Tooltip } from "@mui/material";
|
||||
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon, Download as DownloadIcon, Refresh as RefreshIcon, Mic as MicIcon } from "@mui/icons-material";
|
||||
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, IconButton, Divider, Chip, Tooltip } from "@mui/material";
|
||||
import { CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, Download as DownloadIcon, Refresh as RefreshIcon } from "@mui/icons-material";
|
||||
import { Script, Knobs, Scene } from "../types";
|
||||
import { BlogResearchResponse } from "../../../services/blogWriterApi";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
import { aiApiClient } from "../../../api/client";
|
||||
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
|
||||
import { SceneEditor } from "./SceneEditor";
|
||||
import { InlineAudioPlayer } from "../InlineAudioPlayer";
|
||||
import { aiApiClient } from "../../../api/client";
|
||||
import { BrollInfoPanel } from "./parts/BrollInfoPanel";
|
||||
import { ScriptEditorProvider } from "./ScriptEditorContext";
|
||||
|
||||
interface ScriptEditorProps {
|
||||
projectId: string;
|
||||
@@ -25,6 +27,7 @@ interface ScriptEditorProps {
|
||||
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
|
||||
analysis?: any;
|
||||
outline?: any;
|
||||
podcastMode?: "audio_only" | "video_only" | "audio_video";
|
||||
}
|
||||
|
||||
export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
@@ -43,13 +46,13 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
avatarUrl,
|
||||
analysis,
|
||||
outline,
|
||||
podcastMode = "video_only",
|
||||
}) => {
|
||||
const [script, setScript] = useState<Script | null>(initialScript);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
|
||||
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
|
||||
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(false);
|
||||
const [combiningAudio, setCombiningAudio] = useState(false);
|
||||
const [combinedAudioResult, setCombinedAudioResult] = useState<{
|
||||
url: string;
|
||||
@@ -71,48 +74,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,
|
||||
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, 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
|
||||
@@ -278,54 +241,28 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
}, [script, projectId, onError]);
|
||||
|
||||
return (
|
||||
<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"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.02em",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
fontSize: { xs: "1.75rem", md: "2rem" },
|
||||
}}
|
||||
>
|
||||
<EditNoteIcon sx={{ fontSize: "2rem" }} />
|
||||
Script Editor
|
||||
{knobs.voice_id && (() => {
|
||||
const vid = knobs.voice_id;
|
||||
const isCustom = Boolean(vid && !vid.startsWith("builtin:") && !["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman", "Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man", "Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess", "Sweet_Girl_2", "Exuberant_Girl"].includes(vid));
|
||||
const vName = isCustom ? "My Voice Clone" : (vid === "Wise_Woman" ? "Wise Woman" : vid === "Friendly_Person" ? "Friendly Person" : vid === "Deep_Voice_Man" ? "Deep Voice Man" : vid?.replace(/_/g, " ") || "Default");
|
||||
return (
|
||||
<Chip
|
||||
icon={<MicIcon sx={{ fontSize: "14px !important" }} />}
|
||||
label={`Active Voice: ${vName}`}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 2,
|
||||
background: isCustom ? "rgba(16, 185, 129, 0.1)" : "rgba(99, 102, 241, 0.1)",
|
||||
color: isCustom ? "#10b981" : "#6366f1",
|
||||
border: `1px solid ${isCustom ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)"}`,
|
||||
'& .MuiChip-icon': { color: isCustom ? "#10b981" : "#6366f1" },
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
|
||||
Review and refine your podcast script before rendering
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{loading && (
|
||||
@@ -371,225 +308,6 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
|
||||
{script && (
|
||||
<Stack spacing={3}>
|
||||
{/* Script Format Explanation Panel */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)",
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(99, 102, 241, 0.08)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: showScriptFormatInfo ? 2 : 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
|
||||
}}
|
||||
>
|
||||
<InfoIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
|
||||
Why This Script Format?
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
|
||||
Understanding how your script creates natural, human-like audio
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<IconButton
|
||||
onClick={() => setShowScriptFormatInfo(!showScriptFormatInfo)}
|
||||
sx={{
|
||||
color: "#6366f1",
|
||||
"&:hover": {
|
||||
background: "rgba(99, 102, 241, 0.1)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showScriptFormatInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={showScriptFormatInfo}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.8, mb: 2 }}>
|
||||
Our AI script generator creates scripts specifically optimized for <strong style={{ fontWeight: 600 }}>high-quality text-to-speech</strong>.
|
||||
The format you see here is designed to produce audio that sounds natural and human-like, not robotic.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
1
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Natural Pauses & Rhythm
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
The script includes strategic pauses between lines and when speakers change. This creates natural breathing patterns
|
||||
and conversation flow, just like real human speech. Without these pauses, the audio would sound rushed and robotic.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
2
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Emphasis Markers
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
Lines marked with emphasis help highlight important points, statistics, or key insights. The AI voice will naturally
|
||||
stress these parts, making your podcast more engaging and easier to follow—just like a real host would emphasize important information.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
3
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Short, Conversational Sentences
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
The script uses shorter sentences (15-20 words) written in a conversational style. This matches how people actually
|
||||
speak, making the audio sound more natural. Long, complex sentences would sound awkward when spoken aloud.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
4
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Scene-Specific Emotions
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
Each scene has an emotional tone (excited, serious, curious, etc.) that guides the AI voice's delivery. This creates
|
||||
variety and keeps listeners engaged, just like a real podcast host would vary their tone based on the topic.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
5
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Optimized for Podcast Narration
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
The script is optimized with slightly slower pacing and natural pronunciation settings specifically for podcast narration.
|
||||
This ensures clarity and makes the content easy to understand, even when listeners are multitasking.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mt: 1,
|
||||
background: "rgba(99, 102, 241, 0.06)",
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
"& .MuiAlert-icon": {
|
||||
color: "#6366f1",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
|
||||
<strong style={{ fontWeight: 600 }}>Tip:</strong> You can edit any line or scene to match your preferences.
|
||||
The format will be preserved when rendering, ensuring your audio still sounds natural and professional.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
@@ -607,6 +325,15 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<BrollInfoPanel
|
||||
activeScript={script}
|
||||
generatingChartId={undefined}
|
||||
generateChartPreviews={undefined}
|
||||
regenerateChart={undefined}
|
||||
removeChart={undefined}
|
||||
scenesWithCharts={script.scenes.filter((s) => s.chart_data && Object.keys(s.chart_data).length > 0).length}
|
||||
/>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{script.scenes.map((scene, idx) => (
|
||||
<GlassyCard
|
||||
@@ -624,6 +351,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
approvingSceneId={approvingSceneId}
|
||||
generatingAudioId={generatingAudioId}
|
||||
totalScenes={script.scenes.length}
|
||||
sceneIndex={idx}
|
||||
onAudioGenerationStart={(sceneId) => {
|
||||
setGeneratingAudioId(sceneId);
|
||||
}}
|
||||
@@ -835,6 +563,6 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</ScriptEditorProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||
import { Script, Knobs, Scene, PodcastMode } from "../types";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
import { podcastApi, getCachedVoiceCloneInfo } from "../../../services/podcastApi";
|
||||
import { getApiUrl, getAuthTokenGetter } from "../../../api/client";
|
||||
|
||||
interface ScriptEditorContextType {
|
||||
// State
|
||||
@@ -58,6 +59,27 @@ interface ScriptEditorContextType {
|
||||
|
||||
const ScriptEditorContext = createContext<ScriptEditorContextType | undefined>(undefined);
|
||||
|
||||
const toUsablePreviewUrl = (previewUrl?: string): string | undefined => {
|
||||
if (!previewUrl) return undefined;
|
||||
if (/^https?:\/\//i.test(previewUrl)) return previewUrl;
|
||||
const cleanPath = previewUrl.startsWith("/") ? previewUrl : `/${previewUrl}`;
|
||||
// Build base URL — auth token will be appended lazily when the URL is used
|
||||
return `${getApiUrl()}${cleanPath}`;
|
||||
};
|
||||
|
||||
const appendAuthToken = async (url: string): Promise<string> => {
|
||||
const tokenGetter = getAuthTokenGetter();
|
||||
if (!tokenGetter) return url;
|
||||
try {
|
||||
const token = await tokenGetter();
|
||||
if (token) {
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
return `${url}${separator}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
} catch {}
|
||||
return url;
|
||||
};
|
||||
|
||||
interface ScriptEditorProviderProps {
|
||||
children: ReactNode;
|
||||
projectId: string;
|
||||
@@ -208,6 +230,7 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
|
||||
knobs,
|
||||
speakers,
|
||||
durationMinutes,
|
||||
podcastMode,
|
||||
analysis,
|
||||
outline,
|
||||
})
|
||||
@@ -307,10 +330,14 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
|
||||
lines: scene.lines.map((line) => ({ text: line.text })),
|
||||
}));
|
||||
|
||||
const cachedClone = getCachedVoiceCloneInfo();
|
||||
const result = await podcastApi.generateBatchAudio({
|
||||
scenes: sceneData,
|
||||
voiceId: knobs.voice_id,
|
||||
customVoiceId: knobs.custom_voice_id,
|
||||
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,
|
||||
speed: knobs.voice_speed,
|
||||
emotion: knobs.voice_emotion,
|
||||
englishNormalization: true,
|
||||
@@ -411,9 +438,12 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
|
||||
title: scene.title,
|
||||
});
|
||||
|
||||
const baseUrl = toUsablePreviewUrl(result.preview_url);
|
||||
const authUrl = baseUrl ? await appendAuthToken(baseUrl) : undefined;
|
||||
|
||||
return {
|
||||
...scene,
|
||||
broll_preview_url: result.preview_url,
|
||||
broll_preview_url: authUrl,
|
||||
chart_id: result.chart_id,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -449,9 +479,12 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
|
||||
title: scene.title,
|
||||
});
|
||||
|
||||
const baseUrl = toUsablePreviewUrl(result.preview_url);
|
||||
const authUrl = baseUrl ? await appendAuthToken(baseUrl) : undefined;
|
||||
|
||||
const updatedScenes = activeScript.scenes.map((s) =>
|
||||
s.id === sceneId
|
||||
? { ...s, broll_preview_url: result.preview_url, chart_id: result.chart_id }
|
||||
? { ...s, broll_preview_url: authUrl, chart_id: result.chart_id }
|
||||
: s
|
||||
);
|
||||
|
||||
|
||||
@@ -1,140 +1,403 @@
|
||||
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 React, { useState } from "react";
|
||||
import { Stack, Box, Typography, Paper, Button, CircularProgress, Chip, IconButton, Tooltip, Accordion, AccordionSummary, AccordionDetails, Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon, Fullscreen as FullscreenIcon, ExpandMore as ExpandMoreIcon, Close as CloseIcon, ZoomOutMap as ZoomOutMapIcon } from "@mui/icons-material";
|
||||
import { useScriptEditor } from "../ScriptEditorContext";
|
||||
import { Script } from "../../types";
|
||||
|
||||
interface BrollInfoPanelProps {
|
||||
activeScript?: Script | null;
|
||||
generatingChartId?: string | null;
|
||||
generateChartPreviews?: () => Promise<void>;
|
||||
regenerateChart?: (sceneId: string) => Promise<void>;
|
||||
removeChart?: (sceneId: string) => void;
|
||||
scenesWithCharts?: number;
|
||||
}
|
||||
|
||||
export const BrollInfoPanel: React.FC<BrollInfoPanelProps> = (props) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [previewModal, setPreviewModal] = useState<{ url: string; title: string } | null>(null);
|
||||
const ctx = useScriptEditor();
|
||||
|
||||
export const BrollInfoPanel: React.FC = () => {
|
||||
const {
|
||||
activeScript,
|
||||
generatingChartId,
|
||||
setGeneratingChartId,
|
||||
generateChartPreviews,
|
||||
regenerateChart,
|
||||
removeChart,
|
||||
scenesWithCharts
|
||||
} = useScriptEditor();
|
||||
activeScript: ctxActiveScript,
|
||||
generatingChartId: ctxGeneratingChartId,
|
||||
generateChartPreviews: ctxGenerateChartPreviews,
|
||||
regenerateChart: ctxRegenerateChart,
|
||||
removeChart: ctxRemoveChart,
|
||||
scenesWithCharts: ctxScenesWithCharts
|
||||
} = ctx;
|
||||
|
||||
if (!activeScript || activeScript.scenes.length === 0) {
|
||||
const resolvedActiveScript = props.activeScript ?? ctxActiveScript;
|
||||
const resolvedGeneratingChartId = props.generatingChartId ?? ctxGeneratingChartId;
|
||||
const resolvedGenerateChartPreviews = props.generateChartPreviews ?? ctxGenerateChartPreviews;
|
||||
const resolvedRegenerateChart = props.regenerateChart ?? ctxRegenerateChart;
|
||||
const resolvedRemoveChart = props.removeChart ?? ctxRemoveChart;
|
||||
|
||||
if (!resolvedActiveScript || resolvedActiveScript.scenes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scenesWithData = activeScript.scenes.filter(s => s.chart_data && Object.keys(s.chart_data).length > 0);
|
||||
const scenesWithData = resolvedActiveScript.scenes.filter(s => s.chart_data && Object.keys(s.chart_data).length > 0);
|
||||
const hasChartData = scenesWithData.length > 0;
|
||||
const resolvedScenesWithCharts = props.scenesWithCharts ?? ctxScenesWithCharts ?? scenesWithData.length;
|
||||
|
||||
const openPreview = (url: string, title: string) => {
|
||||
setPreviewModal({ url, title });
|
||||
};
|
||||
|
||||
const closePreview = () => {
|
||||
setPreviewModal(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
<>
|
||||
<Accordion
|
||||
expanded={expanded}
|
||||
onChange={(_, isExpanded) => setExpanded(isExpanded)}
|
||||
sx={{
|
||||
p: 3,
|
||||
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.05) 0%, rgba(16, 185, 129, 0.05) 100%)",
|
||||
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.03) 0%, rgba(16, 185, 129, 0.03) 100%)",
|
||||
border: "1px solid rgba(34, 197, 94, 0.15)",
|
||||
borderRadius: 2,
|
||||
'&:before': {
|
||||
display: 'none',
|
||||
},
|
||||
'&.MuiAccordion-root': {
|
||||
borderRadius: 2,
|
||||
},
|
||||
'& .MuiAccordionSummary-root': {
|
||||
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" }} />
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ color: '#22c55e' }} />}
|
||||
sx={{
|
||||
'& .MuiAccordionSummary-content': {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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 sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
|
||||
B-Roll Charts
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: "#0f172a", lineHeight: 1.2 }}>
|
||||
Podcast Charts
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
|
||||
Programmatic charts extracted from research data
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
|
||||
{resolvedScenesWithCharts} chart{resolvedScenesWithCharts !== 1 ? 's' : ''} for visual storytelling
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{hasChartData && (
|
||||
<Chip
|
||||
label={`${scenesWithData.length} scene${scenesWithData.length > 1 ? 's' : ''} with charts`}
|
||||
size="small"
|
||||
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a", fontWeight: 600 }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
|
||||
{!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}>
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
{hasChartData && (
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={generatingChartId ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
|
||||
onClick={generateChartPreviews}
|
||||
disabled={!!generatingChartId}
|
||||
size="small"
|
||||
startIcon={resolvedGeneratingChartId ? <CircularProgress size={14} color="inherit" /> : <AutoAwesomeIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
resolvedGenerateChartPreviews?.();
|
||||
}}
|
||||
disabled={!!resolvedGeneratingChartId}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)",
|
||||
"&:hover": { background: "linear-gradient(135deg, #16a34a 0%, #059669 100%)" },
|
||||
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)",
|
||||
}
|
||||
}}
|
||||
>
|
||||
{generatingChartId ? "Generating..." : "Generate Chart Previews"}
|
||||
{resolvedGeneratingChartId ? "Generating..." : "Generate Charts"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{scenesWithData.map((scene) => (
|
||||
{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: 2,
|
||||
background: "rgba(0,0,0,0.02)",
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
background: "#fff",
|
||||
borderRadius: 1.5,
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between"
|
||||
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)",
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{/* Thumbnail */}
|
||||
<Box
|
||||
onClick={() => hasPreview && scene.broll_preview_url && openPreview(scene.broll_preview_url, scene.title)}
|
||||
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",
|
||||
position: "relative",
|
||||
"&:hover": hasPreview ? {
|
||||
transform: "scale(1.05)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
"& .zoom-overlay": {
|
||||
opacity: 1,
|
||||
},
|
||||
} : {}
|
||||
}}
|
||||
>
|
||||
{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",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
className="zoom-overlay"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
opacity: 0,
|
||||
transition: "opacity 0.2s ease",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<ZoomOutMapIcon sx={{ color: "#fff", fontSize: 18 }} />
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<BarChartIcon sx={{ fontSize: 20, color: "#94a3b8" }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
{generatingChartId === scene.id ? (
|
||||
<CircularProgress size={20} />
|
||||
) : scene.broll_preview_url ? (
|
||||
<>
|
||||
{/* 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="Preview Ready"
|
||||
label={chartData?.type || "chart"}
|
||||
size="small"
|
||||
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a" }}
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: "0.65rem",
|
||||
background: "rgba(34, 197, 94, 0.1)",
|
||||
color: "#16a34a",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
|
||||
{chartData?.labels?.length || 0} labels
|
||||
</Typography>
|
||||
{hasPreview && (
|
||||
<Chip
|
||||
label="Ready"
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => regenerateChart(scene.id)}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => removeChart(scene.id)}
|
||||
sx={{ color: "#ef4444" }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: "0.65rem",
|
||||
background: "rgba(34, 197, 94, 0.15)",
|
||||
color: "#16a34a",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Actions */}
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
{hasPreview && (
|
||||
<Tooltip title="View fullsize">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => scene.broll_preview_url && openPreview(scene.broll_preview_url, scene.title)}
|
||||
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 || !!resolvedGeneratingChartId}
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
"&:hover": { color: "#f59e0b", background: "rgba(245, 158, 11, 0.1)" }
|
||||
}}
|
||||
>
|
||||
<RefreshIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Remove chart">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => resolvedRemoveChart?.(scene.id)}
|
||||
disabled={!resolvedRemoveChart}
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
"&:hover": { color: "#ef4444", background: "rgba(239, 68, 68, 0.1)" }
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Full-size chart preview modal */}
|
||||
<Dialog
|
||||
open={!!previewModal}
|
||||
onClose={closePreview}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
background: "#0f172a",
|
||||
overflow: "hidden",
|
||||
}
|
||||
}}
|
||||
>
|
||||
{previewModal && (
|
||||
<>
|
||||
<DialogTitle sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
||||
color: "#f1f5f9",
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
}}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<BarChartIcon sx={{ fontSize: 20, color: "#22c55e" }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: "#f1f5f9" }}>
|
||||
{previewModal.title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<IconButton
|
||||
onClick={closePreview}
|
||||
size="small"
|
||||
sx={{ color: "#94a3b8", "&:hover": { color: "#f1f5f9" } }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ p: 0, display: "flex", justifyContent: "center", alignItems: "center", minHeight: 300, background: "#0f172a" }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={previewModal.url}
|
||||
alt={`Chart: ${previewModal.title}`}
|
||||
sx={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "70vh",
|
||||
objectFit: "contain",
|
||||
p: 2,
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -47,6 +50,7 @@ export type Research = {
|
||||
summary: string;
|
||||
keyInsights: ResearchInsight[];
|
||||
factCards: Fact[];
|
||||
sources?: { title: string; url: string; excerpt?: string }[];
|
||||
mappedAngles: {
|
||||
title: string;
|
||||
why: string;
|
||||
@@ -81,6 +85,8 @@ export type Scene = {
|
||||
imagePrompt?: string;
|
||||
chart_data?: Record<string, any>;
|
||||
broll_preview_url?: string;
|
||||
broll_video_url?: string;
|
||||
chart_id?: string;
|
||||
};
|
||||
|
||||
export type Script = {
|
||||
@@ -196,7 +202,7 @@ export type CreateProjectPayload = {
|
||||
export type CreateProjectResult = {
|
||||
projectId: string;
|
||||
analysis: PodcastAnalysis;
|
||||
estimate: PodcastEstimate;
|
||||
estimate: PodcastEstimate | null;
|
||||
queries: Query[];
|
||||
bible?: PodcastBible;
|
||||
avatar_url?: string | null;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,7 +14,7 @@ interface PrimaryButtonProps {
|
||||
size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
export const PrimaryButton = React.forwardRef<HTMLButtonElement, PrimaryButtonProps>(({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
@@ -25,7 +25,7 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
ariaLabel,
|
||||
sx,
|
||||
size = "medium",
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const sizeStyles = {
|
||||
small: { px: 1.5, py: 0.5, fontSize: "0.75rem" },
|
||||
medium: { px: 3, py: 1, fontSize: "0.875rem" },
|
||||
@@ -34,6 +34,7 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="contained"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
@@ -62,10 +63,12 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip title={tooltip} arrow>
|
||||
<span>{button}</span>
|
||||
<span style={{ display: "inline-flex" }}>{button}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
PrimaryButton.displayName = "PrimaryButton";
|
||||
|
||||
|
||||
@@ -153,6 +153,7 @@ export interface GoogleTrendsData {
|
||||
timeframe: string;
|
||||
geo: string;
|
||||
keywords: string[];
|
||||
source?: string;
|
||||
timestamp: string;
|
||||
cached?: boolean;
|
||||
error?: string;
|
||||
|
||||
@@ -68,16 +68,16 @@ export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
|
||||
{
|
||||
label: 'AI Calls',
|
||||
used: currentUsage.total_calls,
|
||||
limit: limits.limits.gemini_calls || limits.limits.openai_calls || 50,
|
||||
limit: limits.limits.ai_text_generation_calls || limits.limits.gemini_calls || limits.limits.openai_calls || 50,
|
||||
color: '#3b82f6',
|
||||
unlimited: false,
|
||||
unlimited: limits.limits.ai_text_generation_calls === 0 && limits.limits.gemini_calls === 0 && limits.limits.openai_calls === 0,
|
||||
},
|
||||
{
|
||||
label: 'Images',
|
||||
used: imageCalls,
|
||||
limit: limits.limits.stability_calls || 50,
|
||||
color: '#a855f7',
|
||||
unlimited: false,
|
||||
unlimited: limits.limits.stability_calls === 0,
|
||||
},
|
||||
{
|
||||
label: 'Videos',
|
||||
@@ -85,6 +85,13 @@ export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
|
||||
limit: limits.limits.video_calls,
|
||||
color: '#ec4899',
|
||||
unlimited: limits.limits.video_calls === 0,
|
||||
},
|
||||
{
|
||||
label: 'Audio',
|
||||
used: currentUsage.provider_breakdown?.audio?.calls ?? 0,
|
||||
limit: limits.limits.audio_calls,
|
||||
color: '#22c55e',
|
||||
unlimited: limits.limits.audio_calls === 0,
|
||||
}
|
||||
].filter(item => item.unlimited || item.limit > 0);
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ try {
|
||||
|
||||
export type AudioGenerationSettings = {
|
||||
voiceId: string;
|
||||
customVoiceId?: string;
|
||||
useVoiceClone?: boolean;
|
||||
voiceSampleUrl?: string;
|
||||
voiceCloneEngine?: string;
|
||||
speed: number;
|
||||
volume: number;
|
||||
pitch: number;
|
||||
|
||||
@@ -44,6 +44,7 @@ interface UsageStats {
|
||||
|
||||
interface UsageLimits {
|
||||
limits: {
|
||||
ai_text_generation_calls: number;
|
||||
gemini_calls: number;
|
||||
openai_calls: number;
|
||||
anthropic_calls: number;
|
||||
@@ -51,8 +52,13 @@ interface UsageLimits {
|
||||
tavily_calls: number;
|
||||
serper_calls: number;
|
||||
metaphor_calls: number;
|
||||
exa_calls: number;
|
||||
firecrawl_calls: number;
|
||||
stability_calls: number;
|
||||
video_calls: number;
|
||||
image_edit_calls: number;
|
||||
audio_calls: number;
|
||||
wavespeed_calls: number;
|
||||
monthly_cost: number;
|
||||
};
|
||||
}
|
||||
@@ -102,10 +108,13 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
checkInterval: 120000, // Check every 2 minutes
|
||||
});
|
||||
|
||||
const fetchUsageData = useCallback(async (period?: string) => {
|
||||
const fetchUsageData = useCallback(async (period?: string, silent = false) => {
|
||||
if (!userId) return;
|
||||
|
||||
// Don't block UI for silent background refreshes (menu open, visibility change)
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const url = period
|
||||
@@ -131,11 +140,15 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
throw new Error(response.data?.error || 'Failed to fetch usage data');
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!silent) {
|
||||
console.error('Error fetching usage data:', err);
|
||||
setError(err.message || 'Failed to load usage statistics');
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const handlePeriodChange = (event: SelectChangeEvent) => {
|
||||
@@ -149,13 +162,30 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
if (userId) {
|
||||
fetchUsageData();
|
||||
}
|
||||
}, [userId, fetchUsageData]); // Added fetchUsageData to deps since it's memoized
|
||||
}, [userId, fetchUsageData]);
|
||||
|
||||
// Refresh on visibility change (user returns to tab) - only if data is stale (>60s old)
|
||||
useEffect(() => {
|
||||
const STALE_THRESHOLD_MS = 60000; // 60 seconds
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && userId && lastUpdated) {
|
||||
const ageMs = Date.now() - lastUpdated.getTime();
|
||||
if (ageMs > STALE_THRESHOLD_MS) {
|
||||
fetchUsageData(selectedPeriod, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, [userId, fetchUsageData, selectedPeriod, lastUpdated]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchUsageData(selectedPeriod);
|
||||
};
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
// Show cached data immediately, don't wait for fetch
|
||||
// Data will refresh when user clicks the manual refresh button
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
@@ -169,11 +199,11 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
};
|
||||
|
||||
const getUsageColor = (current: number, max: number) => {
|
||||
if (max === 0) return '#757575';
|
||||
if (max === 0) return '#9ca3af';
|
||||
const percentage = (current / max) * 100;
|
||||
if (percentage >= 100) return '#d32f2f'; // error
|
||||
if (percentage >= 80) return '#ed6c02'; // warning
|
||||
return '#2e7d32'; // success
|
||||
if (percentage >= 100) return '#dc2626';
|
||||
if (percentage >= 80) return '#ea580c';
|
||||
return '#16a34a';
|
||||
};
|
||||
|
||||
const getProviderDisplayName = (provider: string) => {
|
||||
@@ -237,6 +267,39 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
const monthlyLimit = dashboardData?.limits?.limits?.monthly_cost || 0;
|
||||
const usagePercentage = monthlyLimit > 0 ? (totalCost / monthlyLimit) * 100 : 0;
|
||||
|
||||
// Build per-category usage summaries from provider_breakdown and limits
|
||||
const providerBreakdown = usageData.provider_breakdown || {};
|
||||
const providerLimits = dashboardData?.limits?.limits || {};
|
||||
|
||||
// Aggregate AI text calls (gemini + openai + anthropic + mistral)
|
||||
const aiCalls = (providerBreakdown.gemini?.calls || 0) + (providerBreakdown.openai?.calls || 0) + (providerBreakdown.anthropic?.calls || 0) + (providerBreakdown.mistral?.calls || 0) + (providerBreakdown.huggingface?.calls || 0) + (providerBreakdown.wavespeed?.calls || 0);
|
||||
const aiCallLimit = providerLimits.ai_text_generation_calls || providerLimits.gemini_calls || 0;
|
||||
|
||||
// Image calls (stability + wavespeed image)
|
||||
const imageCalls = (providerBreakdown.stability?.calls || 0) + (providerBreakdown.image_edit?.calls || 0);
|
||||
const imageCallLimit = providerLimits.stability_calls || 0;
|
||||
|
||||
// Audio calls
|
||||
const audioCalls = providerBreakdown.audio?.calls || 0;
|
||||
const audioCallLimit = providerLimits.audio_calls || 0;
|
||||
|
||||
// Video calls
|
||||
const videoCalls = providerBreakdown.video?.calls || 0;
|
||||
const videoCallLimit = providerLimits.video_calls || 0;
|
||||
|
||||
// Research calls (exa + tavily + serper + firecrawl)
|
||||
const researchCalls = (providerBreakdown.exa?.calls || 0) + (providerBreakdown.tavily?.calls || 0) + (providerBreakdown.serper?.calls || 0) + (providerBreakdown.firecrawl?.calls || 0);
|
||||
const researchCallLimit = (providerLimits.exa_calls || 0) + (providerLimits.tavily_calls || 0) + (providerLimits.serper_calls || 0) + (providerLimits.firecrawl_calls || 0);
|
||||
|
||||
// WaveSpeed calls (all WaveSpeed API calls)
|
||||
const wavespeedCalls = providerBreakdown.wavespeed?.calls || 0;
|
||||
const wavespeedCallLimit = providerLimits.wavespeed_calls || 0;
|
||||
|
||||
const formatLimit = (used: number, limit: number) => {
|
||||
if (limit === 0) return `${used} / ∞`;
|
||||
return `${used} / ${limit}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
{/* Priority 2 Alert Banner (Usage limits) */}
|
||||
@@ -261,10 +324,10 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
color: 'text.secondary',
|
||||
color: '#374151',
|
||||
'& .MuiSelect-select': { py: 0.5 }
|
||||
}}
|
||||
IconComponent={() => <CalendarMonth sx={{ fontSize: 16, color: 'action.active', ml: 0.5 }} />}
|
||||
IconComponent={() => <CalendarMonth sx={{ fontSize: 16, color: '#6b7280', ml: 0.5 }} />}
|
||||
>
|
||||
{availablePeriods.map((period) => (
|
||||
<MenuItem key={period} value={period} dense>
|
||||
@@ -295,8 +358,8 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: `${getUsageColor(totalCost, monthlyLimit)}20`,
|
||||
borderColor: getUsageColor(totalCost, monthlyLimit),
|
||||
bgcolor: `${getUsageColor(totalCost, monthlyLimit)}10`,
|
||||
borderColor: `${getUsageColor(totalCost, monthlyLimit)}60`,
|
||||
color: getUsageColor(totalCost, monthlyLimit),
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-icon': {
|
||||
@@ -315,14 +378,14 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
width: 40,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'rgba(0,0,0,0.1)',
|
||||
bgcolor: '#e5e7eb',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: getUsageColor(totalCost, monthlyLimit),
|
||||
borderRadius: 3
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600 }}>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, color: '#374151' }}>
|
||||
{usagePercentage.toFixed(0)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -335,7 +398,8 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
disabled={loading}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }
|
||||
color: '#6b7280',
|
||||
'&:hover': { bgcolor: '#f3f4f6' }
|
||||
}}
|
||||
>
|
||||
<Refresh sx={{ fontSize: 16 }} />
|
||||
@@ -349,12 +413,108 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
onClick={handleMenuOpen}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }
|
||||
color: '#6b7280',
|
||||
'&:hover': { bgcolor: '#f3f4f6' }
|
||||
}}
|
||||
>
|
||||
<MoreVert sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Per-Provider Usage Breakdown */}
|
||||
<Box sx={{ mt: 1.5, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{aiCallLimit > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 500, color: '#6b7280', minWidth: 60 }}>AI Calls</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, ml: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={aiCallLimit > 0 ? Math.min((aiCalls / aiCallLimit) * 100, 100) : 0}
|
||||
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(aiCalls, aiCallLimit), borderRadius: 2 } }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(aiCalls, aiCallLimit), minWidth: 55, textAlign: 'right' }}>
|
||||
{formatLimit(aiCalls, aiCallLimit)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{imageCallLimit > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 500, color: '#6b7280', minWidth: 60 }}>Images</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, ml: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={imageCallLimit > 0 ? Math.min((imageCalls / imageCallLimit) * 100, 100) : 0}
|
||||
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(imageCalls, imageCallLimit), borderRadius: 2 } }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(imageCalls, imageCallLimit), minWidth: 55, textAlign: 'right' }}>
|
||||
{formatLimit(imageCalls, imageCallLimit)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{audioCallLimit > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 500, color: '#6b7280', minWidth: 60 }}>Audio</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, ml: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={audioCallLimit > 0 ? Math.min((audioCalls / audioCallLimit) * 100, 100) : 0}
|
||||
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(audioCalls, audioCallLimit), borderRadius: 2 } }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(audioCalls, audioCallLimit), minWidth: 55, textAlign: 'right' }}>
|
||||
{formatLimit(audioCalls, audioCallLimit)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{videoCallLimit > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 500, color: '#6b7280', minWidth: 60 }}>Video</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, ml: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={videoCallLimit > 0 ? Math.min((videoCalls / videoCallLimit) * 100, 100) : 0}
|
||||
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(videoCalls, videoCallLimit), borderRadius: 2 } }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(videoCalls, videoCallLimit), minWidth: 55, textAlign: 'right' }}>
|
||||
{formatLimit(videoCalls, videoCallLimit)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{researchCallLimit > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 500, color: '#6b7280', minWidth: 60 }}>Research</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, ml: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={researchCallLimit > 0 ? Math.min((researchCalls / researchCallLimit) * 100, 100) : 0}
|
||||
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(researchCalls, researchCallLimit), borderRadius: 2 } }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(researchCalls, researchCallLimit), minWidth: 55, textAlign: 'right' }}>
|
||||
{formatLimit(researchCalls, researchCallLimit)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{wavespeedCallLimit > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 500, color: '#6b7280', minWidth: 60 }}>WaveSpeed</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, ml: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={wavespeedCallLimit > 0 ? Math.min((wavespeedCalls / wavespeedCallLimit) * 100, 100) : 0}
|
||||
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(wavespeedCalls, wavespeedCallLimit), borderRadius: 2 } }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(wavespeedCalls, wavespeedCallLimit), minWidth: 55, textAlign: 'right' }}>
|
||||
{formatLimit(wavespeedCalls, wavespeedCallLimit)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
@@ -362,25 +522,32 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: '#ffffff',
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleViewFullDashboard}>
|
||||
<MenuItem onClick={handleViewFullDashboard} sx={{ color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
|
||||
<Dashboard sx={{ mr: 1, fontSize: 18 }} />
|
||||
View Full Dashboard
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleRefresh}>
|
||||
<MenuItem onClick={handleRefresh} sx={{ color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
|
||||
<Refresh sx={{ mr: 1, fontSize: 18 }} />
|
||||
Refresh Data
|
||||
</MenuItem>
|
||||
{lastUpdated && (
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography variant="caption" sx={{ color: '#9ca3af' }}>
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Menu>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -172,54 +172,60 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
minWidth: 320,
|
||||
maxWidth: 400,
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto'
|
||||
minWidth: 340,
|
||||
maxWidth: 420,
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
bgcolor: '#ffffff',
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.08)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{/* User Info Header */}
|
||||
<Box sx={{ px: 2.5, py: 2, bgcolor: '#f8f9fb', borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1a1a2e', fontSize: '0.9rem' }}>
|
||||
{user?.fullName || user?.username || 'User'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography variant="caption" sx={{ color: '#6b7280', fontSize: '0.75rem' }}>
|
||||
{user?.primaryEmailAddress?.emailAddress}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Subscription Info in Menu */}
|
||||
<Box sx={{ px: 2, py: 1.5, bgcolor: 'rgba(0,0,0,0.02)' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
{/* Subscription Info */}
|
||||
<Box sx={{ px: 2.5, py: 1.5, bgcolor: '#f8f9fb' }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Current Plan
|
||||
</Typography>
|
||||
<Chip
|
||||
label={getPlanLabel()}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: `${getPlanColor()}20`,
|
||||
border: `1px solid ${getPlanColor()}`,
|
||||
bgcolor: `${getPlanColor()}15`,
|
||||
border: `1.5px solid ${getPlanColor()}40`,
|
||||
color: getPlanColor(),
|
||||
fontWeight: 700,
|
||||
fontSize: '0.75rem',
|
||||
height: 26,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Divider sx={{ mx: 2 }} />
|
||||
|
||||
{/* System Status Indicator */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
px: 2.5,
|
||||
py: 1.5,
|
||||
bgcolor: 'rgba(0,0,0,0.02)',
|
||||
bgcolor: '#f8f9fb',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontWeight: 600 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
System Health
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', '& > *': { transform: 'scale(0.85)' } }}>
|
||||
@@ -227,33 +233,33 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Divider sx={{ mx: 2 }} />
|
||||
|
||||
{/* Usage Dashboard */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
px: 2.5,
|
||||
py: 1.5,
|
||||
bgcolor: 'rgba(0,0,0,0.02)',
|
||||
bgcolor: '#ffffff',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontWeight: 600 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Usage Statistics
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
<UsageDashboard compact={true} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Divider sx={{ mx: 2 }} />
|
||||
|
||||
<MenuItem onClick={() => { handleClose(); window.location.href = '/pricing'; }}>
|
||||
<MenuItem onClick={() => { handleClose(); window.location.href = '/pricing'; }} sx={{ mx: 1, borderRadius: 1, color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
|
||||
Manage Subscription
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleSignOut}>Sign out</MenuItem>
|
||||
<MenuItem onClick={handleSignOut} sx={{ mx: 1, borderRadius: 1, color: '#6b7280', '&:hover': { bgcolor: '#fef2f2', color: '#ef4444' } }}>
|
||||
Sign out
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
|
||||
145
frontend/src/components/shared/VoiceClonePanel.tsx
Normal file
145
frontend/src/components/shared/VoiceClonePanel.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Stack,
|
||||
Typography,
|
||||
Collapse,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
ExpandLess,
|
||||
ExpandMore,
|
||||
AutoAwesome,
|
||||
RestartAlt,
|
||||
CheckCircle,
|
||||
Close,
|
||||
} from "@mui/icons-material";
|
||||
import { VoiceAvatarPlaceholder } from "../OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder";
|
||||
|
||||
export interface VoiceClonePanelProps {
|
||||
showVoiceClonePanel: boolean;
|
||||
voiceCreated: boolean;
|
||||
redoingClone: boolean;
|
||||
onTogglePanel: () => void;
|
||||
onVoiceSet: () => void;
|
||||
onCancelRedo: () => void;
|
||||
onDoneWithVoice: () => void;
|
||||
}
|
||||
|
||||
export const VoiceClonePanel: React.FC<VoiceClonePanelProps> = ({
|
||||
showVoiceClonePanel,
|
||||
voiceCreated,
|
||||
redoingClone,
|
||||
onTogglePanel,
|
||||
onVoiceSet,
|
||||
onCancelRedo,
|
||||
onDoneWithVoice,
|
||||
}) => {
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button
|
||||
onClick={onTogglePanel}
|
||||
startIcon={showVoiceClonePanel ? <ExpandLess /> : redoingClone ? <RestartAlt /> : <AutoAwesome />}
|
||||
endIcon={showVoiceClonePanel ? <ExpandLess /> : <ExpandMore />}
|
||||
sx={{
|
||||
py: 2,
|
||||
px: 3,
|
||||
width: "100%",
|
||||
background: showVoiceClonePanel
|
||||
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
: "linear-gradient(135deg, #8B5CF6 0%, #EC4899 50%, #F59E0B 100%)",
|
||||
border: showVoiceClonePanel
|
||||
? "1px solid rgba(102, 126, 234, 0.5)"
|
||||
: "none",
|
||||
borderRadius: 2.5,
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
textTransform: "none",
|
||||
fontSize: "0.95rem",
|
||||
boxShadow: showVoiceClonePanel
|
||||
? "0 4px 15px rgba(102, 126, 234, 0.35)"
|
||||
: "0 4px 20px rgba(139, 92, 246, 0.4), 0 0 30px rgba(236, 72, 153, 0.2)",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #7C3AED 0%, #9333EA 50%, #D97706 100%)",
|
||||
boxShadow: "0 6px 25px rgba(139, 92, 246, 0.5)",
|
||||
transform: "translateY(-1px)",
|
||||
},
|
||||
transition: "all 0.3s ease",
|
||||
}}
|
||||
>
|
||||
{redoingClone ? "Redo Voice Clone" : showVoiceClonePanel ? "Hide Voice Cloning" : "Create Your Voice Clone ✨"}
|
||||
</Button>
|
||||
|
||||
<Collapse in={showVoiceClonePanel}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)",
|
||||
border: "1px solid rgba(102, 126, 234, 0.2)",
|
||||
boxShadow: "inset 0 1px 3px rgba(0,0,0,0.05)",
|
||||
}}
|
||||
>
|
||||
<VoiceAvatarPlaceholder
|
||||
domainName="Podcast"
|
||||
onVoiceSet={onVoiceSet}
|
||||
/>
|
||||
|
||||
{voiceCreated && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)",
|
||||
border: "1px solid rgba(16, 185, 129, 0.3)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<CheckCircle sx={{ color: "#10b981", fontSize: "1.25rem" }} />
|
||||
<Typography variant="subtitle2" sx={{ color: "#10b981", fontWeight: 700 }}>
|
||||
{redoingClone ? "Voice Clone Updated!" : "Voice Clone Created Successfully!"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#475569", mb: 1.5, fontSize: "0.875rem" }}>
|
||||
{redoingClone ? "Your voice clone has been updated and will be used for your podcast." : "Your custom voice clone is ready and will be used for your podcast."}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1.5} justifyContent="flex-end">
|
||||
<Button
|
||||
onClick={onCancelRedo}
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
"&:hover": { color: "#1e293b", background: "rgba(0,0,0,0.04)" },
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onDoneWithVoice}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
px: 3,
|
||||
boxShadow: "0 4px 12px rgba(16, 185, 129, 0.3)",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #059669 0%, #047857 100%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -16,9 +16,6 @@ import {
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Collapse,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
@@ -43,36 +40,22 @@ import {
|
||||
Category,
|
||||
} from "@mui/icons-material";
|
||||
import { getLatestVoiceClone, VoiceCloneResponse } from "../../api/brandAssets";
|
||||
import { getAuthTokenGetter, getApiUrl } from "../../api/client";
|
||||
import { VoiceAvatarPlaceholder } from "../OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder";
|
||||
|
||||
export type VoiceOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
personality?: string;
|
||||
isCustom?: boolean;
|
||||
previewUrl?: string;
|
||||
gender?: "male" | "female";
|
||||
category?: string;
|
||||
};
|
||||
|
||||
export type VoiceAudioSettings = {
|
||||
speed: number;
|
||||
volume: number;
|
||||
pitch: number;
|
||||
emotion: string;
|
||||
};
|
||||
|
||||
const DEFAULT_AUDIO_SETTINGS: VoiceAudioSettings = {
|
||||
speed: 1.0,
|
||||
volume: 1.0,
|
||||
pitch: 0,
|
||||
emotion: "neutral",
|
||||
};
|
||||
|
||||
const EMOTION_OPTIONS = ["neutral", "happy", "sad", "angry", "fearful", "disgusted", "surprised"];
|
||||
|
||||
type GenderFilter = "all" | "male" | "female";
|
||||
type CategoryFilter = string;
|
||||
import { useVoicePreview } from "./useVoicePreview";
|
||||
import { useVoiceFiltering } from "./useVoiceFiltering";
|
||||
import { VoiceClonePanel } from "./VoiceClonePanel";
|
||||
import {
|
||||
VoiceOption,
|
||||
VoiceAudioSettings,
|
||||
DEFAULT_AUDIO_SETTINGS,
|
||||
EMOTION_OPTIONS,
|
||||
VOICE_PREVIEW_MAP,
|
||||
CATEGORY_OPTIONS,
|
||||
PREDEFINED_VOICES,
|
||||
CategoryFilter,
|
||||
VoiceSelectorGenderFilter,
|
||||
} from "./voiceConstants";
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
value: string;
|
||||
@@ -84,59 +67,7 @@ interface VoiceSelectorProps {
|
||||
onAudioSettingsChange?: (settings: VoiceAudioSettings) => void;
|
||||
}
|
||||
|
||||
const VOICE_SAMPLE_BASE = "/assets/voice-samples";
|
||||
|
||||
const VOICE_PREVIEW_MAP: Record<string, string> = {
|
||||
Wise_Woman: `${VOICE_SAMPLE_BASE}/wise_woman.mp3`,
|
||||
Friendly_Person: `${VOICE_SAMPLE_BASE}/friendly_person.mp3`,
|
||||
Inspirational_girl: `${VOICE_SAMPLE_BASE}/inspirational_girl.mp3`,
|
||||
Deep_Voice_Man: `${VOICE_SAMPLE_BASE}/deep_voice_man.mp3`,
|
||||
Calm_Woman: `${VOICE_SAMPLE_BASE}/calm_woman.mp3`,
|
||||
Casual_Guy: `${VOICE_SAMPLE_BASE}/casual_guy.mp3`,
|
||||
Lively_Girl: `${VOICE_SAMPLE_BASE}/lively_girl.mp3`,
|
||||
Patient_Man: `${VOICE_SAMPLE_BASE}/patient_man.mp3`,
|
||||
Young_Knight: `${VOICE_SAMPLE_BASE}/young_knight.mp3`,
|
||||
Determined_Man: `${VOICE_SAMPLE_BASE}/determined_man.mp3`,
|
||||
Lovely_Girl: `${VOICE_SAMPLE_BASE}/lovely_girl.mp3`,
|
||||
Decent_Boy: `${VOICE_SAMPLE_BASE}/decent_boy.mp3`,
|
||||
Imposing_Manner: `${VOICE_SAMPLE_BASE}/imposing_manner.mp3`,
|
||||
Elegant_Man: `${VOICE_SAMPLE_BASE}/elegant_man.mp3`,
|
||||
Abbess: `${VOICE_SAMPLE_BASE}/abbess.mp3`,
|
||||
Sweet_Girl_2: `${VOICE_SAMPLE_BASE}/sweet_girl.mp3`,
|
||||
Exuberant_Girl: `${VOICE_SAMPLE_BASE}/exuberant_girl.mp3`,
|
||||
};
|
||||
|
||||
const CATEGORY_OPTIONS: { value: CategoryFilter; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "educational", label: "Educational" },
|
||||
{ value: "marketing", label: "Marketing" },
|
||||
{ value: "professional", label: "Professional" },
|
||||
{ value: "creative", label: "Creative" },
|
||||
{ value: "calming", label: "Calming" },
|
||||
{ value: "motivational", label: "Motivational" },
|
||||
];
|
||||
|
||||
const PREDEFINED_VOICES: VoiceOption[] = [
|
||||
{ id: "Wise_Woman", name: "Wise Woman", personality: "Authoritative, trustworthy female voice - perfect for educational content", previewUrl: VOICE_PREVIEW_MAP.Wise_Woman, gender: "female", category: "educational" },
|
||||
{ id: "Friendly_Person", name: "Friendly Person", personality: "Warm, approachable voice - great for welcoming introductions", previewUrl: VOICE_PREVIEW_MAP.Friendly_Person, category: "marketing" },
|
||||
{ id: "Inspirational_girl", name: "Inspirational Girl", personality: "Motivational, uplifting female voice - ideal for inspiration", previewUrl: VOICE_PREVIEW_MAP.Inspirational_girl, gender: "female", category: "motivational" },
|
||||
{ id: "Deep_Voice_Man", name: "Deep Voice Man", personality: "Powerful, commanding male voice - excellent for serious topics", previewUrl: VOICE_PREVIEW_MAP.Deep_Voice_Man, gender: "male", category: "professional" },
|
||||
{ id: "Calm_Woman", name: "Calm Woman", personality: "Soothing, composed female voice - perfect for meditation or sensitive topics", previewUrl: VOICE_PREVIEW_MAP.Calm_Woman, gender: "female", category: "calming" },
|
||||
{ id: "Casual_Guy", name: "Casual Guy", personality: "Relaxed, conversational male voice - great for vlogs and tutorials", previewUrl: VOICE_PREVIEW_MAP.Casual_Guy, gender: "male", category: "marketing" },
|
||||
{ id: "Lively_Girl", name: "Lively Girl", personality: "Energetic, enthusiastic female voice - ideal for exciting announcements", previewUrl: VOICE_PREVIEW_MAP.Lively_Girl, gender: "female", category: "marketing" },
|
||||
{ id: "Patient_Man", name: "Patient Man", personality: "Gentle, understanding male voice - perfect for explanations", previewUrl: VOICE_PREVIEW_MAP.Patient_Man, gender: "male", category: "educational" },
|
||||
{ id: "Young_Knight", name: "Young Knight", personality: "Brave, confident male voice - great for adventure and gaming", previewUrl: VOICE_PREVIEW_MAP.Young_Knight, gender: "male", category: "creative" },
|
||||
{ id: "Determined_Man", name: "Determined Man", personality: "Strong, resolute male voice - excellent for motivational speeches", previewUrl: VOICE_PREVIEW_MAP.Determined_Man, gender: "male", category: "motivational" },
|
||||
{ id: "Lovely_Girl", name: "Lovely Girl", personality: "Sweet, charming female voice - ideal for storytelling", previewUrl: VOICE_PREVIEW_MAP.Lovely_Girl, gender: "female", category: "creative" },
|
||||
{ id: "Decent_Boy", name: "Decent Boy", personality: "Honest, sincere male voice - perfect for testimonials", previewUrl: VOICE_PREVIEW_MAP.Decent_Boy, gender: "male", category: "marketing" },
|
||||
{ id: "Imposing_Manner", name: "Imposing Manner", personality: "Formal, dignified male voice - great for corporate content", previewUrl: VOICE_PREVIEW_MAP.Imposing_Manner, gender: "male", category: "professional" },
|
||||
{ id: "Elegant_Man", name: "Elegant Man", personality: "Refined, sophisticated male voice - ideal for luxury content", previewUrl: VOICE_PREVIEW_MAP.Elegant_Man, gender: "male", category: "professional" },
|
||||
{ id: "Abbess", name: "Abbess", personality: "Spiritual, serene female voice - perfect for meditation", previewUrl: VOICE_PREVIEW_MAP.Abbess, gender: "female", category: "calming" },
|
||||
{ id: "Sweet_Girl_2", name: "Sweet Girl 2", personality: "Gentle, melodic female voice - excellent for children's content", previewUrl: VOICE_PREVIEW_MAP.Sweet_Girl_2, gender: "female", category: "creative" },
|
||||
{ id: "Exuberant_Girl", name: "Exuberant Girl", personality: "Joyful, expressive female voice - ideal for celebrations", previewUrl: VOICE_PREVIEW_MAP.Exuberant_Girl, gender: "female", category: "creative" },
|
||||
];
|
||||
|
||||
const VOICE_CLONE_ID = "MY_VOICE_CLONE";
|
||||
export const VOICE_CLONE_ID = "MY_VOICE_CLONE";
|
||||
|
||||
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
value,
|
||||
@@ -149,10 +80,8 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
}) => {
|
||||
const [voiceClone, setVoiceClone] = useState<VoiceCloneResponse | null>(null);
|
||||
const [loadingVoiceClone, setLoadingVoiceClone] = useState(false);
|
||||
const [playingPreview, setPlayingPreview] = useState<string | null>(null);
|
||||
const [showVoiceClonePanel, setShowVoiceClonePanel] = useState(false);
|
||||
const [voiceCreated, setVoiceCreated] = useState(false);
|
||||
const [useCreatedVoice, setUseCreatedVoice] = useState(true);
|
||||
const [redoingClone, setRedoingClone] = useState(false);
|
||||
const [selectOpen, setSelectOpen] = useState(false);
|
||||
const [tuneModalOpen, setTuneModalOpen] = useState(false);
|
||||
@@ -160,12 +89,23 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
const [localAudioSettings, setLocalAudioSettings] = useState<VoiceAudioSettings>(
|
||||
externalAudioSettings || { ...DEFAULT_AUDIO_SETTINGS }
|
||||
);
|
||||
const [genderFilter, setGenderFilter] = useState<GenderFilter>("all");
|
||||
const [genderFilter, setGenderFilter] = useState<VoiceSelectorGenderFilter>("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>("all");
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const prevVoiceCloneIdRef = useRef<string | null>(null);
|
||||
|
||||
const fetchVoiceClone = async () => {
|
||||
const { playingPreview, handlePreview, stopCurrentAudio } = useVoicePreview();
|
||||
|
||||
const isPreviewing = playingPreview !== null;
|
||||
|
||||
const { voiceOptions, filteredVoices } = useVoiceFiltering({
|
||||
showVoiceClone,
|
||||
voiceClone,
|
||||
value,
|
||||
genderFilter,
|
||||
categoryFilter,
|
||||
});
|
||||
|
||||
const fetchVoiceClone = useCallback(async () => {
|
||||
try {
|
||||
setLoadingVoiceClone(true);
|
||||
const result = await getLatestVoiceClone();
|
||||
@@ -177,36 +117,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
} finally {
|
||||
setLoadingVoiceClone(false);
|
||||
}
|
||||
};
|
||||
|
||||
const voiceOptions = useMemo(() => {
|
||||
const options: VoiceOption[] = [...PREDEFINED_VOICES];
|
||||
|
||||
if (showVoiceClone && voiceClone?.success && voiceClone.custom_voice_id) {
|
||||
options.unshift({
|
||||
id: VOICE_CLONE_ID,
|
||||
name: voiceClone.voice_name || voiceClone.custom_voice_id || "My Voice Clone",
|
||||
personality: "Your own voice - cloned from audio sample",
|
||||
isCustom: true,
|
||||
previewUrl: voiceClone.preview_audio_url,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [showVoiceClone, voiceClone]);
|
||||
|
||||
const filteredVoices = useMemo(() => {
|
||||
const filtered = PREDEFINED_VOICES.filter(v => {
|
||||
if (genderFilter !== "all" && v.gender !== genderFilter) return false;
|
||||
if (categoryFilter !== "all" && v.category !== categoryFilter) return false;
|
||||
return true;
|
||||
});
|
||||
if (value && value !== VOICE_CLONE_ID && !filtered.some(v => v.id === value)) {
|
||||
const selected = PREDEFINED_VOICES.find(v => v.id === value);
|
||||
if (selected) filtered.unshift(selected);
|
||||
}
|
||||
return filtered;
|
||||
}, [genderFilter, categoryFilter, value]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showVoiceClone) return;
|
||||
@@ -225,61 +136,6 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
}
|
||||
}, [voiceClone]);
|
||||
|
||||
const stopCurrentAudio = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
audioRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePreview = useCallback((voice: VoiceOption) => {
|
||||
if (!voice.previewUrl) return;
|
||||
|
||||
if (playingPreview === voice.id) {
|
||||
stopCurrentAudio();
|
||||
setPlayingPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
stopCurrentAudio();
|
||||
setPlayingPreview(voice.id);
|
||||
|
||||
const audio = new Audio(voice.previewUrl);
|
||||
audioRef.current = audio;
|
||||
|
||||
audio.onerror = () => {
|
||||
console.error("Failed to load voice preview audio:", voice.previewUrl);
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null;
|
||||
}
|
||||
setPlayingPreview(null);
|
||||
};
|
||||
|
||||
audio.onended = () => {
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null;
|
||||
}
|
||||
setPlayingPreview(null);
|
||||
};
|
||||
|
||||
audio.play().catch((err) => {
|
||||
console.error("Failed to play voice preview:", err);
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null;
|
||||
}
|
||||
setPlayingPreview(null);
|
||||
});
|
||||
}, [playingPreview, stopCurrentAudio]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopCurrentAudio();
|
||||
};
|
||||
}, [stopCurrentAudio]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
if (newValue === VOICE_CLONE_ID && voiceClone?.success) {
|
||||
onChange(voiceClone.custom_voice_id || VOICE_CLONE_ID);
|
||||
@@ -306,7 +162,6 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
|
||||
const handleVoiceSet = useCallback(() => {
|
||||
setVoiceCreated(true);
|
||||
setUseCreatedVoice(true);
|
||||
}, []);
|
||||
|
||||
const handleRedoClone = useCallback(() => {
|
||||
@@ -315,18 +170,15 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
setRedoingClone(true);
|
||||
setShowVoiceClonePanel(true);
|
||||
setVoiceCreated(false);
|
||||
setUseCreatedVoice(true);
|
||||
}, 150);
|
||||
}, []);
|
||||
|
||||
const handleDoneWithVoice = useCallback(() => {
|
||||
if (useCreatedVoice) {
|
||||
fetchVoiceClone();
|
||||
}
|
||||
setShowVoiceClonePanel(false);
|
||||
setVoiceCreated(false);
|
||||
setRedoingClone(false);
|
||||
}, [useCreatedVoice]);
|
||||
}, []);
|
||||
|
||||
const handleCancelRedo = useCallback(() => {
|
||||
setShowVoiceClonePanel(false);
|
||||
@@ -342,13 +194,10 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
} else {
|
||||
setShowVoiceClonePanel(true);
|
||||
setVoiceCreated(false);
|
||||
setUseCreatedVoice(true);
|
||||
setRedoingClone(false);
|
||||
}
|
||||
}, [showVoiceClonePanel]);
|
||||
|
||||
const isPreviewing = playingPreview !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (externalAudioSettings) {
|
||||
setLocalAudioSettings(externalAudioSettings);
|
||||
@@ -727,7 +576,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
key={val}
|
||||
label={label}
|
||||
size="small"
|
||||
onClick={() => setGenderFilter(val as GenderFilter)}
|
||||
onClick={() => setGenderFilter(val as VoiceSelectorGenderFilter)}
|
||||
variant={genderFilter === val ? "filled" : "outlined"}
|
||||
sx={{
|
||||
height: 22,
|
||||
@@ -976,132 +825,15 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
)}
|
||||
|
||||
{(showVoiceClone && !voiceClone?.success) || redoingClone ? (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button
|
||||
onClick={handleTogglePanel}
|
||||
startIcon={showVoiceClonePanel ? <ExpandLess /> : redoingClone ? <RestartAlt /> : <AutoAwesome />}
|
||||
endIcon={showVoiceClonePanel ? <ExpandLess /> : <ExpandMore />}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
width: "100%",
|
||||
background: showVoiceClonePanel
|
||||
? "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)"
|
||||
: "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)",
|
||||
border: showVoiceClonePanel
|
||||
? "1px solid rgba(102, 126, 234, 0.3)"
|
||||
: "1px dashed rgba(102, 126, 234, 0.4)",
|
||||
borderRadius: 2,
|
||||
color: "#667eea",
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
fontSize: "0.875rem",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%)",
|
||||
borderColor: "#667eea",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.15)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{redoingClone ? "Redo Voice Clone" : showVoiceClonePanel ? "Hide Voice Cloning" : "Create Your Voice Clone"}
|
||||
</Button>
|
||||
|
||||
<Collapse in={showVoiceClonePanel}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)",
|
||||
border: "1px solid rgba(102, 126, 234, 0.2)",
|
||||
boxShadow: "inset 0 1px 3px rgba(0,0,0,0.05)",
|
||||
}}
|
||||
>
|
||||
<VoiceAvatarPlaceholder
|
||||
domainName="Podcast"
|
||||
<VoiceClonePanel
|
||||
showVoiceClonePanel={showVoiceClonePanel}
|
||||
voiceCreated={voiceCreated}
|
||||
redoingClone={redoingClone}
|
||||
onTogglePanel={handleTogglePanel}
|
||||
onVoiceSet={handleVoiceSet}
|
||||
onCancelRedo={handleCancelRedo}
|
||||
onDoneWithVoice={handleDoneWithVoice}
|
||||
/>
|
||||
|
||||
{voiceCreated && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)",
|
||||
border: "1px solid rgba(16, 185, 129, 0.3)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
|
||||
<CheckCircle sx={{ color: "#10b981", fontSize: "1.25rem" }} />
|
||||
<Typography variant="subtitle2" sx={{ color: "#10b981", fontWeight: 700 }}>
|
||||
{redoingClone ? "Voice Clone Updated!" : "Voice Clone Created Successfully!"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#475569", mb: 1.5, fontSize: "0.875rem" }}>
|
||||
{redoingClone ? "Your voice clone has been updated." : "Your custom voice clone is ready. Would you like to use this voice for your podcast?"}
|
||||
</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={useCreatedVoice}
|
||||
onChange={(e) => setUseCreatedVoice(e.target.checked)}
|
||||
sx={{
|
||||
color: "#10b981",
|
||||
"&.Mui-checked": { color: "#10b981" },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography sx={{ color: "#1e293b", fontWeight: 500, fontSize: "0.9375rem" }}>
|
||||
Use this voice for my podcast
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 2, borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
|
||||
<Stack direction="row" spacing={1.5} justifyContent="flex-end">
|
||||
<Button
|
||||
onClick={handleCancelRedo}
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
"&:hover": { color: "#1e293b", background: "rgba(0,0,0,0.04)" },
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleDoneWithVoice}
|
||||
sx={{
|
||||
background: useCreatedVoice
|
||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
px: 3,
|
||||
boxShadow: useCreatedVoice
|
||||
? "0 4px 12px rgba(16, 185, 129, 0.3)"
|
||||
: "0 4px 12px rgba(102, 126, 234, 0.3)",
|
||||
"&:hover": {
|
||||
background: useCreatedVoice
|
||||
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
|
||||
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{useCreatedVoice ? "Use This Voice" : "Done"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Voice Fine-tune Modal */}
|
||||
|
||||
56
frontend/src/components/shared/useVoiceFiltering.ts
Normal file
56
frontend/src/components/shared/useVoiceFiltering.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from "react";
|
||||
import { VoiceOption, PREDEFINED_VOICES, VoiceSelectorGenderFilter, CategoryFilter } from "./voiceConstants";
|
||||
import { VoiceCloneResponse } from "../../api/brandAssets";
|
||||
import { VOICE_CLONE_ID } from "./VoiceSelector";
|
||||
|
||||
export interface UseVoiceFilteringParams {
|
||||
showVoiceClone: boolean;
|
||||
voiceClone: VoiceCloneResponse | null;
|
||||
value: string;
|
||||
genderFilter: VoiceSelectorGenderFilter;
|
||||
categoryFilter: CategoryFilter;
|
||||
}
|
||||
|
||||
export interface UseVoiceFilteringReturn {
|
||||
voiceOptions: VoiceOption[];
|
||||
filteredVoices: VoiceOption[];
|
||||
}
|
||||
|
||||
export const useVoiceFiltering = ({
|
||||
showVoiceClone,
|
||||
voiceClone,
|
||||
value,
|
||||
genderFilter,
|
||||
categoryFilter,
|
||||
}: UseVoiceFilteringParams): UseVoiceFilteringReturn => {
|
||||
const voiceOptions = useMemo(() => {
|
||||
const options: VoiceOption[] = [...PREDEFINED_VOICES];
|
||||
|
||||
if (showVoiceClone && voiceClone?.success && voiceClone.custom_voice_id) {
|
||||
options.unshift({
|
||||
id: VOICE_CLONE_ID,
|
||||
name: voiceClone.voice_name || voiceClone.custom_voice_id || "My Voice Clone",
|
||||
personality: "Your own voice - cloned from audio sample",
|
||||
isCustom: true,
|
||||
previewUrl: voiceClone.preview_audio_url,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [showVoiceClone, voiceClone]);
|
||||
|
||||
const filteredVoices = useMemo(() => {
|
||||
const filtered = PREDEFINED_VOICES.filter(v => {
|
||||
if (genderFilter !== "all" && v.gender !== genderFilter) return false;
|
||||
if (categoryFilter !== "all" && v.category !== categoryFilter) return false;
|
||||
return true;
|
||||
});
|
||||
if (value && value !== VOICE_CLONE_ID && !filtered.some(v => v.id === value)) {
|
||||
const selected = PREDEFINED_VOICES.find(v => v.id === value);
|
||||
if (selected) filtered.unshift(selected);
|
||||
}
|
||||
return filtered;
|
||||
}, [genderFilter, categoryFilter, value]);
|
||||
|
||||
return { voiceOptions, filteredVoices };
|
||||
};
|
||||
102
frontend/src/components/shared/useVoicePreview.ts
Normal file
102
frontend/src/components/shared/useVoicePreview.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { VoiceOption } from "./voiceConstants";
|
||||
import { getAuthTokenGetter, getApiUrl } from "../../api/client";
|
||||
|
||||
export interface UseVoicePreviewReturn {
|
||||
playingPreview: string | null;
|
||||
handlePreview: (voice: VoiceOption) => Promise<void>;
|
||||
stopCurrentAudio: () => void;
|
||||
}
|
||||
|
||||
export const useVoicePreview = (): UseVoicePreviewReturn => {
|
||||
const [playingPreview, setPlayingPreview] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const stopCurrentAudio = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
audioRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePreview = useCallback(async (voice: VoiceOption) => {
|
||||
if (!voice.previewUrl) return;
|
||||
|
||||
if (playingPreview === voice.id) {
|
||||
stopCurrentAudio();
|
||||
setPlayingPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
stopCurrentAudio();
|
||||
setPlayingPreview(voice.id);
|
||||
|
||||
let previewUrl = voice.previewUrl;
|
||||
|
||||
// For local development with frontend dev server, don't prepend API URL
|
||||
// The frontend serves static files from /public/ through webpack dev server
|
||||
const isLocalDev = window.location.hostname === 'localhost' && !previewUrl.includes('/api/');
|
||||
if (!isLocalDev && previewUrl.startsWith('/')) {
|
||||
previewUrl = `${getApiUrl()}${previewUrl}`;
|
||||
}
|
||||
|
||||
if (isLocalDev) {
|
||||
console.log("[VoicePreview] Local dev - using relative URL:", previewUrl);
|
||||
} else {
|
||||
console.log("[VoicePreview] Full URL:", previewUrl);
|
||||
}
|
||||
try {
|
||||
const tokenGetter = getAuthTokenGetter();
|
||||
if (tokenGetter) {
|
||||
const token = await tokenGetter();
|
||||
if (token && previewUrl.includes('/api/')) {
|
||||
const separator = previewUrl.includes('?') ? '&' : '?';
|
||||
previewUrl = `${previewUrl}${separator}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Token retrieval failed — try URL without token
|
||||
}
|
||||
|
||||
const audio = new Audio(previewUrl);
|
||||
audioRef.current = audio;
|
||||
|
||||
audio.onerror = () => {
|
||||
console.error("Failed to load voice preview audio:", voice.previewUrl);
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null;
|
||||
}
|
||||
setPlayingPreview(null);
|
||||
};
|
||||
|
||||
audio.onended = () => {
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null;
|
||||
}
|
||||
setPlayingPreview(null);
|
||||
};
|
||||
|
||||
audio.play().catch((err) => {
|
||||
console.error("Failed to play voice preview:", err);
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null;
|
||||
}
|
||||
setPlayingPreview(null);
|
||||
});
|
||||
}, [playingPreview, stopCurrentAudio]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopCurrentAudio();
|
||||
};
|
||||
}, [stopCurrentAudio]);
|
||||
|
||||
return {
|
||||
playingPreview,
|
||||
handlePreview,
|
||||
stopCurrentAudio,
|
||||
};
|
||||
};
|
||||
81
frontend/src/components/shared/voiceConstants.ts
Normal file
81
frontend/src/components/shared/voiceConstants.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export type VoiceOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
personality?: string;
|
||||
isCustom?: boolean;
|
||||
previewUrl?: string;
|
||||
gender?: "male" | "female";
|
||||
category?: string;
|
||||
};
|
||||
|
||||
export type VoiceAudioSettings = {
|
||||
speed: number;
|
||||
volume: number;
|
||||
pitch: number;
|
||||
emotion: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_AUDIO_SETTINGS: VoiceAudioSettings = {
|
||||
speed: 1.0,
|
||||
volume: 1.0,
|
||||
pitch: 0,
|
||||
emotion: "neutral",
|
||||
};
|
||||
|
||||
export const EMOTION_OPTIONS = ["neutral", "happy", "sad", "angry", "fearful", "disgusted", "surprised"];
|
||||
|
||||
export const VOICE_SAMPLE_BASE = "/assets/voice-samples";
|
||||
|
||||
export const VOICE_PREVIEW_MAP: Record<string, string> = {
|
||||
Wise_Woman: `${VOICE_SAMPLE_BASE}/wise_woman.mp3`,
|
||||
Friendly_Person: `${VOICE_SAMPLE_BASE}/friendly_person.mp3`,
|
||||
Inspirational_girl: `${VOICE_SAMPLE_BASE}/inspirational_girl.mp3`,
|
||||
Deep_Voice_Man: `${VOICE_SAMPLE_BASE}/deep_voice_man.mp3`,
|
||||
Calm_Woman: `${VOICE_SAMPLE_BASE}/calm_woman.mp3`,
|
||||
Casual_Guy: `${VOICE_SAMPLE_BASE}/casual_guy.mp3`,
|
||||
Lively_Girl: `${VOICE_SAMPLE_BASE}/lively_girl.mp3`,
|
||||
Patient_Man: `${VOICE_SAMPLE_BASE}/patient_man.mp3`,
|
||||
Young_Knight: `${VOICE_SAMPLE_BASE}/young_knight.mp3`,
|
||||
Determined_Man: `${VOICE_SAMPLE_BASE}/determined_man.mp3`,
|
||||
Lovely_Girl: `${VOICE_SAMPLE_BASE}/lovely_girl.mp3`,
|
||||
Decent_Boy: `${VOICE_SAMPLE_BASE}/decent_boy.mp3`,
|
||||
Imposing_Manner: `${VOICE_SAMPLE_BASE}/imposing_manner.mp3`,
|
||||
Elegant_Man: `${VOICE_SAMPLE_BASE}/elegant_man.mp3`,
|
||||
Abbess: `${VOICE_SAMPLE_BASE}/abbess.mp3`,
|
||||
Sweet_Girl_2: `${VOICE_SAMPLE_BASE}/sweet_girl.mp3`,
|
||||
Exuberant_Girl: `${VOICE_SAMPLE_BASE}/exuberant_girl.mp3`,
|
||||
};
|
||||
|
||||
export type CategoryFilter = string;
|
||||
|
||||
export const CATEGORY_OPTIONS: { value: CategoryFilter; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "educational", label: "Educational" },
|
||||
{ value: "marketing", label: "Marketing" },
|
||||
{ value: "professional", label: "Professional" },
|
||||
{ value: "creative", label: "Creative" },
|
||||
{ value: "calming", label: "Calming" },
|
||||
{ value: "motivational", label: "Motivational" },
|
||||
];
|
||||
|
||||
export const PREDEFINED_VOICES: VoiceOption[] = [
|
||||
{ id: "Wise_Woman", name: "Wise Woman", personality: "Authoritative, trustworthy female voice - perfect for educational content", previewUrl: VOICE_PREVIEW_MAP.Wise_Woman, gender: "female", category: "educational" },
|
||||
{ id: "Friendly_Person", name: "Friendly Person", personality: "Warm, approachable voice - great for welcoming introductions", previewUrl: VOICE_PREVIEW_MAP.Friendly_Person, category: "marketing" },
|
||||
{ id: "Inspirational_girl", name: "Inspirational Girl", personality: "Motivational, uplifting female voice - ideal for inspiration", previewUrl: VOICE_PREVIEW_MAP.Inspirational_girl, gender: "female", category: "motivational" },
|
||||
{ id: "Deep_Voice_Man", name: "Deep Voice Man", personality: "Powerful, commanding male voice - excellent for serious topics", previewUrl: VOICE_PREVIEW_MAP.Deep_Voice_Man, gender: "male", category: "professional" },
|
||||
{ id: "Calm_Woman", name: "Calm Woman", personality: "Soothing, composed female voice - perfect for meditation or sensitive topics", previewUrl: VOICE_PREVIEW_MAP.Calm_Woman, gender: "female", category: "calming" },
|
||||
{ id: "Casual_Guy", name: "Casual Guy", personality: "Relaxed, conversational male voice - great for vlogs and tutorials", previewUrl: VOICE_PREVIEW_MAP.Casual_Guy, gender: "male", category: "marketing" },
|
||||
{ id: "Lively_Girl", name: "Lively Girl", personality: "Energetic, enthusiastic female voice - ideal for exciting announcements", previewUrl: VOICE_PREVIEW_MAP.Lively_Girl, gender: "female", category: "marketing" },
|
||||
{ id: "Patient_Man", name: "Patient Man", personality: "Gentle, understanding male voice - perfect for explanations", previewUrl: VOICE_PREVIEW_MAP.Patient_Man, gender: "male", category: "educational" },
|
||||
{ id: "Young_Knight", name: "Young Knight", personality: "Brave, confident male voice - great for adventure and gaming", previewUrl: VOICE_PREVIEW_MAP.Young_Knight, gender: "male", category: "creative" },
|
||||
{ id: "Determined_Man", name: "Determined Man", personality: "Strong, resolute male voice - excellent for motivational speeches", previewUrl: VOICE_PREVIEW_MAP.Determined_Man, gender: "male", category: "motivational" },
|
||||
{ id: "Lovely_Girl", name: "Lovely Girl", personality: "Sweet, charming female voice - ideal for storytelling", previewUrl: VOICE_PREVIEW_MAP.Lovely_Girl, gender: "female", category: "creative" },
|
||||
{ id: "Decent_Boy", name: "Decent Boy", personality: "Honest, sincere male voice - perfect for testimonials", previewUrl: VOICE_PREVIEW_MAP.Decent_Boy, gender: "male", category: "marketing" },
|
||||
{ id: "Imposing_Manner", name: "Imposing Manner", personality: "Formal, dignified male voice - great for corporate content", previewUrl: VOICE_PREVIEW_MAP.Imposing_Manner, gender: "male", category: "professional" },
|
||||
{ id: "Elegant_Man", name: "Elegant Man", personality: "Refined, sophisticated male voice - ideal for luxury content", previewUrl: VOICE_PREVIEW_MAP.Elegant_Man, gender: "male", category: "professional" },
|
||||
{ id: "Abbess", name: "Abbess", personality: "Spiritual, serene female voice - perfect for meditation", previewUrl: VOICE_PREVIEW_MAP.Abbess, gender: "female", category: "calming" },
|
||||
{ id: "Sweet_Girl_2", name: "Sweet Girl 2", personality: "Gentle, melodic female voice - excellent for children's content", previewUrl: VOICE_PREVIEW_MAP.Sweet_Girl_2, gender: "female", category: "creative" },
|
||||
{ id: "Exuberant_Girl", name: "Exuberant Girl", personality: "Joyful, expressive female voice - ideal for celebrations", previewUrl: VOICE_PREVIEW_MAP.Exuberant_Girl, gender: "female", category: "creative" },
|
||||
];
|
||||
|
||||
export type VoiceSelectorGenderFilter = "all" | "male" | "female";
|
||||
@@ -10,6 +10,7 @@ import { saveNavigationState, getCurrentPhaseForTool } from '../utils/navigation
|
||||
import { showSubscriptionExpiredToast, showUsageLimitToast, showSubscriptionToast } from '../utils/toastNotifications';
|
||||
|
||||
export interface SubscriptionLimits {
|
||||
ai_text_generation_calls: number;
|
||||
gemini_calls: number;
|
||||
openai_calls: number;
|
||||
anthropic_calls: number;
|
||||
@@ -17,8 +18,13 @@ export interface SubscriptionLimits {
|
||||
tavily_calls: number;
|
||||
serper_calls: number;
|
||||
metaphor_calls: number;
|
||||
exa_calls: number;
|
||||
firecrawl_calls: number;
|
||||
stability_calls: number;
|
||||
video_calls: number;
|
||||
image_edit_calls: number;
|
||||
audio_calls: number;
|
||||
wavespeed_calls: number;
|
||||
monthly_cost: number;
|
||||
}
|
||||
|
||||
@@ -141,11 +147,11 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
// Continue anyway - apiClient interceptor will handle missing token gracefully
|
||||
}
|
||||
|
||||
console.log('SubscriptionContext: Checking subscription for user:', userId);
|
||||
if (process.env.NODE_ENV === 'development') console.log('SubscriptionContext: Checking subscription for user:', userId);
|
||||
const response = await apiClient.get(`/api/subscription/status/${userId}`);
|
||||
const subscriptionData = response.data.data;
|
||||
|
||||
console.log('SubscriptionContext: Received subscription data from backend:', subscriptionData);
|
||||
if (process.env.NODE_ENV === 'development') console.log('SubscriptionContext: Subscription data received:', { active: subscriptionData?.active, plan: subscriptionData?.plan });
|
||||
setSubscription(subscriptionData);
|
||||
// Update ref immediately so callbacks can access latest value
|
||||
subscriptionRef.current = subscriptionData;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
PodcastBible,
|
||||
} from '../components/PodcastMaker/types';
|
||||
import { BlogResearchResponse, ResearchProvider } from '../services/blogWriterApi';
|
||||
import { podcastApi } from '../services/podcastApi';
|
||||
import { podcastApi, getCachedVoiceCloneInfo } from '../services/podcastApi';
|
||||
|
||||
export interface PodcastProjectState {
|
||||
// Project metadata
|
||||
@@ -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 = {
|
||||
@@ -67,12 +70,39 @@ 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",
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge voice clone cache into knobs if the project knobs don't already have it.
|
||||
* This ensures projects created before voice clone, or after a new clone is made,
|
||||
* automatically pick up the latest voice clone info.
|
||||
*/
|
||||
function mergeVoiceCloneCacheIntoKnobs(knobs: Knobs): Knobs {
|
||||
// If knobs already has a custom voice ID, trust it (user explicitly set it)
|
||||
if (knobs.custom_voice_id) {
|
||||
return knobs;
|
||||
}
|
||||
const cached = getCachedVoiceCloneInfo();
|
||||
if (!cached || !cached.isVoiceClone) {
|
||||
return knobs;
|
||||
}
|
||||
return {
|
||||
...knobs,
|
||||
voice_id: knobs.voice_id || "Wise_Woman",
|
||||
custom_voice_id: cached.customVoiceId,
|
||||
is_voice_clone: true,
|
||||
voice_sample_url: cached.voiceSampleUrl,
|
||||
voice_clone_engine: cached.engine || "qwen3",
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: PodcastProjectState = {
|
||||
project: null,
|
||||
analysis: null,
|
||||
@@ -162,21 +192,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 +232,37 @@ export const usePodcastProjectState = () => {
|
||||
status: state.currentStep === 'render' && state.renderJobs.every(j => j.status === 'completed') ? 'completed' : 'in_progress',
|
||||
};
|
||||
|
||||
await podcastApi.saveProject(projectId, dbState);
|
||||
} catch (error) {
|
||||
console.error('Error syncing project to database:', error);
|
||||
// Don't throw - localStorage is still working
|
||||
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`);
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('[Sync] Error saving project:', error);
|
||||
}
|
||||
}, 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 +270,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 +291,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 +309,7 @@ export const usePodcastProjectState = () => {
|
||||
...prev,
|
||||
scriptData,
|
||||
currentStep: scriptData ? 'render' : prev.currentStep,
|
||||
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
}, []);
|
||||
@@ -432,7 +470,7 @@ export const usePodcastProjectState = () => {
|
||||
scriptData: dbProject.script_data,
|
||||
bible: dbProject.bible,
|
||||
renderJobs: dbProject.render_jobs || [],
|
||||
knobs: dbProject.knobs || DEFAULT_KNOBS,
|
||||
knobs: mergeVoiceCloneCacheIntoKnobs({ ...DEFAULT_KNOBS, ...(dbProject.knobs || {}) }),
|
||||
researchProvider: dbProject.research_provider || 'exa',
|
||||
budgetCap: dbProject.budget_cap || 50,
|
||||
showScriptEditor: dbProject.show_script_editor || false,
|
||||
|
||||
150
frontend/src/hooks/useSpeechToText.ts
Normal file
150
frontend/src/hooks/useSpeechToText.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
export interface UseSpeechToTextReturn {
|
||||
isRecording: boolean;
|
||||
recordingSeconds: number;
|
||||
audioBlob: Blob | null;
|
||||
error: string | null;
|
||||
isSupported: boolean;
|
||||
startRecording: () => Promise<void>;
|
||||
stopRecording: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const MAX_RECORDING_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Reusable hook for recording audio from the browser microphone.
|
||||
* Extracted and generalized from VoiceAvatarPlaceholder.tsx recording logic.
|
||||
*/
|
||||
export const useSpeechToText = (): UseSpeechToTextReturn => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordingSeconds, setRecordingSeconds] = useState(0);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<BlobPart[]>([]);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const isSupported = typeof window !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== 'undefined';
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
recorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
setIsRecording(false);
|
||||
setRecordingSeconds(0);
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
try {
|
||||
if (recorderRef.current && recorderRef.current.state !== 'inactive') {
|
||||
recorderRef.current.stop();
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
}, [cleanup]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!isSupported) {
|
||||
setError('Microphone is not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setAudioBlob(null);
|
||||
cleanup();
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: MediaRecorder.isTypeSupported('audio/webm')
|
||||
? 'audio/webm'
|
||||
: 'audio/mp4';
|
||||
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
recorderRef.current = recorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data && e.data.size > 0) {
|
||||
chunksRef.current.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
try {
|
||||
const chunks = [...chunksRef.current];
|
||||
const blob = new Blob(chunks, { type: mimeType });
|
||||
setAudioBlob(blob);
|
||||
} catch (err: any) {
|
||||
setError('Failed to create audio recording. Please try again.');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onerror = () => {
|
||||
setError('Recording error occurred. Please try again.');
|
||||
cleanup();
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
setRecordingSeconds(0);
|
||||
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setRecordingSeconds((s) => {
|
||||
const next = s + 1;
|
||||
if (next >= MAX_RECORDING_SECONDS) {
|
||||
stopRecording();
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to access microphone');
|
||||
cleanup();
|
||||
}
|
||||
}, [isSupported, cleanup, stopRecording]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setAudioBlob(null);
|
||||
setError(null);
|
||||
cleanup();
|
||||
}, [cleanup]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
recordingSeconds,
|
||||
audioBlob,
|
||||
error,
|
||||
isSupported,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -80,6 +80,18 @@ export const useSubscriptionGuard = (options: SubscriptionGuardOptions = {}) =>
|
||||
return subscription.limits.firecrawl_calls;
|
||||
case 'stability_calls':
|
||||
return subscription.limits.stability_calls;
|
||||
case 'video_calls':
|
||||
return subscription.limits.video_calls || 0;
|
||||
case 'image_edit_calls':
|
||||
return subscription.limits.image_edit_calls || 0;
|
||||
case 'audio_calls':
|
||||
return subscription.limits.audio_calls || 0;
|
||||
case 'ai_text_generation_calls':
|
||||
return subscription.limits.ai_text_generation_calls || 0;
|
||||
case 'exa_calls':
|
||||
return subscription.limits.exa_calls || 0;
|
||||
case 'wavespeed_calls':
|
||||
return subscription.limits.wavespeed_calls || 0;
|
||||
case 'monthly_cost':
|
||||
return subscription.limits.monthly_cost;
|
||||
default:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -142,6 +147,7 @@ const defaultLimits = {
|
||||
plan_name: 'Unknown Plan',
|
||||
tier: 'free' as const,
|
||||
limits: {
|
||||
ai_text_generation_calls: 0,
|
||||
gemini_calls: 0,
|
||||
openai_calls: 0,
|
||||
anthropic_calls: 0,
|
||||
@@ -149,8 +155,13 @@ const defaultLimits = {
|
||||
tavily_calls: 0,
|
||||
serper_calls: 0,
|
||||
metaphor_calls: 0,
|
||||
exa_calls: 0,
|
||||
firecrawl_calls: 0,
|
||||
stability_calls: 0,
|
||||
video_calls: 0,
|
||||
image_edit_calls: 0,
|
||||
audio_calls: 0,
|
||||
wavespeed_calls: 0,
|
||||
gemini_tokens: 0,
|
||||
openai_tokens: 0,
|
||||
anthropic_tokens: 0,
|
||||
@@ -187,6 +198,7 @@ function coerceUsageStats(raw: any): UsageStats {
|
||||
plan_name: raw?.limits?.plan_name ?? 'free',
|
||||
tier: raw?.limits?.tier ?? 'free',
|
||||
limits: {
|
||||
ai_text_generation_calls: raw?.limits?.limits?.ai_text_generation_calls ?? 0,
|
||||
gemini_calls: raw?.limits?.limits?.gemini_calls ?? 0,
|
||||
openai_calls: raw?.limits?.limits?.openai_calls ?? 0,
|
||||
anthropic_calls: raw?.limits?.limits?.anthropic_calls ?? 0,
|
||||
@@ -194,10 +206,13 @@ function coerceUsageStats(raw: any): UsageStats {
|
||||
tavily_calls: raw?.limits?.limits?.tavily_calls ?? 0,
|
||||
serper_calls: raw?.limits?.limits?.serper_calls ?? 0,
|
||||
metaphor_calls: raw?.limits?.limits?.metaphor_calls ?? 0,
|
||||
exa_calls: raw?.limits?.limits?.exa_calls ?? 0,
|
||||
firecrawl_calls: raw?.limits?.limits?.firecrawl_calls ?? 0,
|
||||
stability_calls: raw?.limits?.limits?.stability_calls ?? 0,
|
||||
video_calls: raw?.limits?.limits?.video_calls ?? 0,
|
||||
image_edit_calls: raw?.limits?.limits?.image_edit_calls ?? 0,
|
||||
audio_calls: raw?.limits?.limits?.audio_calls ?? 0,
|
||||
wavespeed_calls: raw?.limits?.limits?.wavespeed_calls ?? 0,
|
||||
gemini_tokens: raw?.limits?.limits?.gemini_tokens ?? 0,
|
||||
openai_tokens: raw?.limits?.limits?.openai_tokens ?? 0,
|
||||
anthropic_tokens: raw?.limits?.limits?.anthropic_tokens ?? 0,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { noteBackendRecovered } from "../api/client";
|
||||
import { ResearchProvider, ResearchConfig } from "./blogWriterApi";
|
||||
import {
|
||||
storyWriterApi,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Knobs,
|
||||
PodcastAnalysis,
|
||||
PodcastEstimate,
|
||||
PodcastMode,
|
||||
Query,
|
||||
RenderJobResult,
|
||||
Research,
|
||||
@@ -27,12 +29,74 @@ 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",
|
||||
};
|
||||
|
||||
const VOICE_CLONE_STORAGE_KEY = "alwrity_voice_clone_info";
|
||||
const VOICE_CLONE_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
function _readVoiceCloneCache() {
|
||||
try {
|
||||
const raw = localStorage.getItem(VOICE_CLONE_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed.timestamp === "number" && Date.now() - parsed.timestamp < VOICE_CLONE_CACHE_TTL) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
/* ignore corrupt localStorage */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _writeVoiceCloneCache(info: {
|
||||
customVoiceId?: string;
|
||||
voiceSampleUrl?: string;
|
||||
engine?: string;
|
||||
isVoiceClone?: boolean;
|
||||
}) {
|
||||
try {
|
||||
localStorage.setItem(VOICE_CLONE_STORAGE_KEY, JSON.stringify({ ...info, timestamp: Date.now() }));
|
||||
} catch {
|
||||
/* ignore localStorage errors (e.g. quota exceeded) */
|
||||
}
|
||||
}
|
||||
|
||||
function _clearVoiceCloneCache() {
|
||||
try {
|
||||
localStorage.removeItem(VOICE_CLONE_STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached voice clone info from localStorage (survives page refresh).
|
||||
* Returns null if expired (>30 min) or not set.
|
||||
*/
|
||||
export function getCachedVoiceCloneInfo() {
|
||||
return _readVoiceCloneCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist voice clone info to localStorage so it survives page refresh
|
||||
* and is available across tabs.
|
||||
*/
|
||||
export function setCachedVoiceCloneInfo(info: {
|
||||
customVoiceId?: string;
|
||||
voiceSampleUrl?: string;
|
||||
engine?: string;
|
||||
isVoiceClone?: boolean;
|
||||
}) {
|
||||
_writeVoiceCloneCache(info);
|
||||
}
|
||||
|
||||
// const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const createId = (prefix: string) => {
|
||||
@@ -59,39 +123,41 @@ const deriveSegments = (option?: OptionLike): string[] => {
|
||||
return segments.slice(0, 5);
|
||||
};
|
||||
|
||||
const estimateCosts = ({
|
||||
minutes,
|
||||
scenes,
|
||||
chars,
|
||||
quality,
|
||||
avatars,
|
||||
queryCount = 3,
|
||||
voiceId,
|
||||
}: {
|
||||
minutes: number;
|
||||
scenes: number;
|
||||
chars: number;
|
||||
quality: string;
|
||||
avatars: number;
|
||||
queryCount?: number;
|
||||
voiceId?: string;
|
||||
}): PodcastEstimate => {
|
||||
const secs = Math.max(60, minutes * 60);
|
||||
const ttsCost = (chars / 1000) * 0.05;
|
||||
const avatarCost = avatars * 0.15;
|
||||
const videoRate = quality === "hd" ? 0.06 : 0.03;
|
||||
const videoCost = secs * videoRate;
|
||||
const researchCost = +(Math.max(1, queryCount) * 0.1).toFixed(2);
|
||||
const total = +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2);
|
||||
const isCustomVoice = Boolean(voiceId && !["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman", "Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man", "Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess", "Sweet_Girl_2", "Exuberant_Girl"].includes(voiceId));
|
||||
const voiceName = isCustomVoice ? "My Voice Clone" : (!voiceId ? "Wise Woman" : voiceId.replace(/_/g, " "));
|
||||
const toPodcastEstimate = (raw: any, voiceId?: string): PodcastEstimate | null => {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const numeric = ["ttsCost", "avatarCost", "videoCost", "researchCost", "total"] as const;
|
||||
if (numeric.some((key) => typeof raw[key] !== "number" || Number.isNaN(raw[key]))) {
|
||||
return null;
|
||||
}
|
||||
const isCustomVoice = Boolean(
|
||||
voiceId &&
|
||||
![
|
||||
"Wise_Woman",
|
||||
"Friendly_Person",
|
||||
"Inspirational_girl",
|
||||
"Deep_Voice_Man",
|
||||
"Calm_Woman",
|
||||
"Casual_Guy",
|
||||
"Lively_Girl",
|
||||
"Patient_Man",
|
||||
"Young_Knight",
|
||||
"Determined_Man",
|
||||
"Lovely_Girl",
|
||||
"Decent_Boy",
|
||||
"Imposing_Manner",
|
||||
"Elegant_Man",
|
||||
"Abbess",
|
||||
"Sweet_Girl_2",
|
||||
"Exuberant_Girl",
|
||||
].includes(voiceId)
|
||||
);
|
||||
return {
|
||||
ttsCost: +ttsCost.toFixed(2),
|
||||
avatarCost: +avatarCost.toFixed(2),
|
||||
videoCost: +videoCost.toFixed(2),
|
||||
researchCost,
|
||||
total,
|
||||
voiceName,
|
||||
ttsCost: raw.ttsCost,
|
||||
avatarCost: raw.avatarCost,
|
||||
videoCost: raw.videoCost,
|
||||
researchCost: raw.researchCost,
|
||||
total: raw.total,
|
||||
voiceName: isCustomVoice ? "My Voice Clone" : (!voiceId ? "Wise Woman" : voiceId.replace(/_/g, " ")),
|
||||
isCustomVoice,
|
||||
};
|
||||
};
|
||||
@@ -179,6 +245,8 @@ type ExaResearchResult = {
|
||||
currency?: "USD";
|
||||
last_updated?: string;
|
||||
};
|
||||
cost?: { total?: number };
|
||||
estimate?: PodcastEstimate | null;
|
||||
search_type?: string;
|
||||
provider?: string;
|
||||
content?: string;
|
||||
@@ -194,6 +262,8 @@ const mapExaResearchResponse = (response: any): Research => {
|
||||
source_indices: insight.source_indices || []
|
||||
}));
|
||||
|
||||
// Backend keys must match PodcastExaResearchResponse exactly:
|
||||
// expert_quotes, listener_cta_suggestions, mapped_angles
|
||||
const expertQuotes = (response.expert_quotes || []).map((eq: any) => ({
|
||||
quote: eq.quote || eq.text || "",
|
||||
source_index: eq.source_index ?? 0
|
||||
@@ -207,10 +277,17 @@ const mapExaResearchResponse = (response: any): Research => {
|
||||
mappedFactIds: angle.mapped_fact_ids || angle.mappedFactIds || []
|
||||
}));
|
||||
|
||||
const sources = (response.sources || []).map((source: any) => ({
|
||||
title: source.title || "",
|
||||
url: source.url || "",
|
||||
excerpt: source.excerpt || source.highlights?.[0] || ""
|
||||
}));
|
||||
|
||||
return {
|
||||
summary,
|
||||
keyInsights,
|
||||
factCards,
|
||||
sources,
|
||||
mappedAngles,
|
||||
expertQuotes,
|
||||
listenerCta,
|
||||
@@ -230,9 +307,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);
|
||||
@@ -302,15 +379,7 @@ export const podcastApi = {
|
||||
// so users can manually choose which queries to run
|
||||
|
||||
const projectId = createId("podcast");
|
||||
const estimate = estimateCosts({
|
||||
minutes: payload.duration,
|
||||
scenes: Math.ceil((payload.duration * 60) / (payload.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target)),
|
||||
chars: Math.max(1000, payload.duration * 900),
|
||||
quality: payload.knobs.bitrate || "standard",
|
||||
avatars: payload.speakers,
|
||||
queryCount: queries.length || 3,
|
||||
voiceId: payload.knobs.voice_id,
|
||||
});
|
||||
const estimate = toPodcastEstimate(analysisResp.data?.estimate, payload.knobs.voice_id);
|
||||
|
||||
return {
|
||||
projectId,
|
||||
@@ -323,11 +392,86 @@ export const podcastApi = {
|
||||
};
|
||||
},
|
||||
|
||||
async enhanceIdea(params: { idea: string; bible?: any }): Promise<{ enhanced_ideas: string[]; rationales: string[] }> {
|
||||
async getWebsiteExtraction(): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
const response = await aiApiClient.get("/api/podcast/website-extraction");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async saveWebsiteExtraction(data: any): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
const response = await aiApiClient.post("/api/podcast/website-extraction", data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async saveTopicContext(projectId: string, topicContext: any): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
const response = await aiApiClient.post(`/api/podcast/project/${projectId}/topic-context`, topicContext);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getTopicContext(projectId: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
const response = await aiApiClient.get(`/api/podcast/project/${projectId}/topic-context`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async enhanceIdea(params: { idea: string; bible?: any; website_data?: any; topic_context?: any }): Promise<{ enhanced_ideas: string[]; rationales: string[] }> {
|
||||
const response = await aiApiClient.post("/api/podcast/idea/enhance", params);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getTrendingTopics(params: {
|
||||
keywords: string[];
|
||||
timeframe?: string;
|
||||
geo?: string;
|
||||
source?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
interest_over_time: any[];
|
||||
interest_by_region: any[];
|
||||
related_topics: { top: any[]; rising: any[] };
|
||||
related_queries: { top: any[]; rising: any[] };
|
||||
timeframe: string;
|
||||
geo: string;
|
||||
keywords: string[];
|
||||
source: string;
|
||||
cached: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
const response = await aiApiClient.post("/api/podcast/trends", {
|
||||
keywords: params.keywords,
|
||||
timeframe: params.timeframe || "today 12-m",
|
||||
geo: params.geo || "US",
|
||||
source: params.source || "web", // 'web' = Google, 'podcast' = YouTube
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async extractUrl(params: { url: string }): Promise<{
|
||||
success: boolean;
|
||||
title?: string;
|
||||
text?: string;
|
||||
summary?: string;
|
||||
highlights?: string[];
|
||||
author?: string;
|
||||
url: string;
|
||||
image?: string;
|
||||
favicon?: string;
|
||||
subpages?: Array<{id: string; title: string; url: string; summary: string; text: string}>;
|
||||
error?: string;
|
||||
}> {
|
||||
const response = await aiApiClient.post("/api/podcast/extract-url", params);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async transcribeAudio(audioBlob: Blob): Promise<{ text: string; error?: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audioBlob, `recording_${Date.now()}.webm`);
|
||||
const response = await aiApiClient.post("/api/podcast/transcribe", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async runResearch(params: {
|
||||
projectId: string;
|
||||
topic: string;
|
||||
@@ -337,7 +481,7 @@ export const podcastApi = {
|
||||
bible?: any;
|
||||
analysis?: PodcastAnalysis | null;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ research: Research; raw: any }> {
|
||||
}): Promise<{ research: Research; raw: any; estimate?: PodcastEstimate | null }> {
|
||||
const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean);
|
||||
if (!keywords.length) {
|
||||
throw new Error("At least one query must be approved for research.");
|
||||
@@ -373,7 +517,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;
|
||||
@@ -384,7 +530,11 @@ export const podcastApi = {
|
||||
params.onProgress("Deep research completed with Exa.");
|
||||
}
|
||||
const mapped = mapExaResearchResponse(exaResult);
|
||||
return { research: mapped, raw: exaResult };
|
||||
return {
|
||||
research: mapped,
|
||||
raw: exaResult,
|
||||
estimate: toPodcastEstimate(exaResult.estimate, params.analysis?.suggestedKnobs?.voice_id),
|
||||
};
|
||||
},
|
||||
|
||||
async generateScript(params: {
|
||||
@@ -394,6 +544,7 @@ export const podcastApi = {
|
||||
knobs: Knobs;
|
||||
speakers: number;
|
||||
durationMinutes: number;
|
||||
podcastMode?: PodcastMode;
|
||||
bible?: any;
|
||||
outline?: any;
|
||||
analysis?: PodcastAnalysis | null;
|
||||
@@ -418,6 +569,7 @@ export const podcastApi = {
|
||||
bible: params.bible,
|
||||
outline: params.outline,
|
||||
analysis: params.analysis,
|
||||
podcast_mode: params.podcastMode || "video_only",
|
||||
});
|
||||
|
||||
if (params.onProgress) {
|
||||
@@ -444,6 +596,7 @@ export const podcastApi = {
|
||||
},
|
||||
],
|
||||
approved: false,
|
||||
chart_data: scene.chart_data || scene.chartData || undefined,
|
||||
}));
|
||||
|
||||
return { scenes: scriptScenes };
|
||||
@@ -484,6 +637,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;
|
||||
@@ -577,17 +733,20 @@ export const podcastApi = {
|
||||
text: textToUse,
|
||||
voice_id: params.voiceId || "Wise_Woman",
|
||||
custom_voice_id: params.customVoiceId || null,
|
||||
speed: params.speed ?? 1.0, // Normal speed (was 0.9, but too slow - causing duration issues)
|
||||
use_voice_clone: params.useVoiceClone || false,
|
||||
voice_sample_url: params.voiceSampleUrl || null,
|
||||
voice_clone_engine: params.voiceCloneEngine || null,
|
||||
speed: params.speed ?? 1.0,
|
||||
volume: params.volume ?? 1.0,
|
||||
pitch: params.pitch ?? 0.0,
|
||||
emotion: sceneEmotion,
|
||||
english_normalization: params.englishNormalization ?? true, // Better number reading for statistics
|
||||
english_normalization: params.englishNormalization ?? true,
|
||||
sample_rate: params.sampleRate || null,
|
||||
bitrate: params.bitrate || null,
|
||||
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,
|
||||
@@ -610,12 +769,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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -735,6 +896,14 @@ export const podcastApi = {
|
||||
seed?: number;
|
||||
maskImageUrl?: string;
|
||||
}): Promise<{ taskId: string; status: string; message: string }> {
|
||||
// Preflight check for video generation
|
||||
await ensurePreflight({
|
||||
provider: 'video',
|
||||
model: 'kling-v2.5-turbo-5s',
|
||||
operation_type: 'video_generation',
|
||||
actual_provider_name: 'wavespeed',
|
||||
});
|
||||
|
||||
const response = await aiApiClient.post("/api/podcast/render/video", {
|
||||
project_id: params.projectId,
|
||||
scene_id: params.sceneId,
|
||||
@@ -833,6 +1002,14 @@ export const podcastApi = {
|
||||
cost: number;
|
||||
image_prompt?: string;
|
||||
}> {
|
||||
// Preflight check for image generation
|
||||
await ensurePreflight({
|
||||
provider: 'stability',
|
||||
model: 'stability-ai',
|
||||
operation_type: 'image_generation',
|
||||
actual_provider_name: 'wavespeed',
|
||||
});
|
||||
|
||||
const response = await aiApiClient.post("/api/podcast/image", {
|
||||
scene_id: params.sceneId,
|
||||
scene_title: params.sceneTitle,
|
||||
@@ -939,6 +1116,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;
|
||||
@@ -958,8 +1138,97 @@ export const podcastApi = {
|
||||
chart_data: Record<string, any>;
|
||||
chart_type: string;
|
||||
title: string;
|
||||
}): Promise<{ image_url: string; preview_url: string; chart_id: string }> {
|
||||
const response = await aiApiClient.post('/api/podcast/chart/preview', params);
|
||||
}): Promise<{ preview_url: string; chart_id: string }> {
|
||||
const response = await aiApiClient.post('/api/podcast/broll/preview/chart', params);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async researchByCategory(params: {
|
||||
category: "news" | "finance" | "research-paper" | "personal-site";
|
||||
keyword?: string;
|
||||
maxResults?: number;
|
||||
websiteUrl?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
category: string;
|
||||
provider: string;
|
||||
topics: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
favicon?: string;
|
||||
}>;
|
||||
query?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const response = await aiApiClient.post('/api/podcast/research/tavily-category', {
|
||||
category: params.category,
|
||||
keyword: params.keyword,
|
||||
max_results: params.maxResults,
|
||||
website_url: params.websiteUrl,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async preEstimateCost(params: {
|
||||
duration: number;
|
||||
speakers: number;
|
||||
queryCount: number;
|
||||
podcastMode: string;
|
||||
gemini_model?: string;
|
||||
audio_tts_model?: string;
|
||||
voice_clone_engine?: string;
|
||||
image_model?: string;
|
||||
video_model?: string;
|
||||
}): Promise<{
|
||||
estimate?: {
|
||||
// Individual costs
|
||||
analysisCost: number;
|
||||
researchCost: number;
|
||||
researchSearchCost: number;
|
||||
researchLlmCost: number;
|
||||
scriptCost: number;
|
||||
ttsCost: number;
|
||||
voiceCloneCost: number;
|
||||
avatarCost: number;
|
||||
videoCost: number;
|
||||
total: number;
|
||||
// Category totals
|
||||
llmCost: number;
|
||||
audioCost: number;
|
||||
mediaCost: number;
|
||||
// Metadata
|
||||
currency: string;
|
||||
source: string;
|
||||
models: {
|
||||
llm: string;
|
||||
research: string;
|
||||
audio_tts: string;
|
||||
voice_clone: string;
|
||||
image: string;
|
||||
video: string;
|
||||
};
|
||||
assumptions: Record<string, number>;
|
||||
} | null;
|
||||
error?: string | null;
|
||||
pricing_available?: boolean;
|
||||
debug?: {
|
||||
pricing_rows: number;
|
||||
providers: string[];
|
||||
};
|
||||
}> {
|
||||
const response = await aiApiClient.post('/api/podcast/pre-estimate', {
|
||||
duration: params.duration,
|
||||
speakers: params.speakers,
|
||||
query_count: params.queryCount,
|
||||
podcast_mode: params.podcastMode,
|
||||
gemini_model: params.gemini_model,
|
||||
audio_tts_model: params.audio_tts_model,
|
||||
voice_clone_engine: params.voice_clone_engine,
|
||||
image_model: params.image_model,
|
||||
video_model: params.video_model,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface SubscriptionLimits {
|
||||
plan_name: string;
|
||||
tier: 'free' | 'basic' | 'pro' | 'enterprise';
|
||||
limits: {
|
||||
ai_text_generation_calls: number;
|
||||
gemini_calls: number;
|
||||
openai_calls: number;
|
||||
anthropic_calls: number;
|
||||
@@ -56,10 +57,13 @@ export interface SubscriptionLimits {
|
||||
tavily_calls: number;
|
||||
serper_calls: number;
|
||||
metaphor_calls: number;
|
||||
exa_calls: number;
|
||||
firecrawl_calls: number;
|
||||
stability_calls: number;
|
||||
video_calls: number;
|
||||
image_edit_calls: number;
|
||||
audio_calls: number;
|
||||
wavespeed_calls: number;
|
||||
gemini_tokens: number;
|
||||
openai_tokens: number;
|
||||
anthropic_tokens: number;
|
||||
@@ -207,6 +211,7 @@ export const SubscriptionLimitsSchema = z.object({
|
||||
plan_name: z.string(),
|
||||
tier: z.enum(['free', 'basic', 'pro', 'enterprise']),
|
||||
limits: z.object({
|
||||
ai_text_generation_calls: z.number().optional().default(0),
|
||||
gemini_calls: z.number(),
|
||||
openai_calls: z.number(),
|
||||
anthropic_calls: z.number(),
|
||||
@@ -214,10 +219,13 @@ export const SubscriptionLimitsSchema = z.object({
|
||||
tavily_calls: z.number(),
|
||||
serper_calls: z.number(),
|
||||
metaphor_calls: z.number(),
|
||||
exa_calls: z.number().optional().default(0),
|
||||
firecrawl_calls: z.number(),
|
||||
stability_calls: z.number(),
|
||||
video_calls: z.number().optional().default(0),
|
||||
image_edit_calls: z.number().optional().default(0),
|
||||
audio_calls: z.number().optional().default(0),
|
||||
wavespeed_calls: z.number().optional().default(0),
|
||||
gemini_tokens: z.number(),
|
||||
openai_tokens: z.number(),
|
||||
anthropic_tokens: z.number(),
|
||||
|
||||
20
frontend/src/utils/devLogger.ts
Normal file
20
frontend/src/utils/devLogger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
export const devLog = {
|
||||
log: (...args: any[]) => { if (isDev) console.log(...args); },
|
||||
warn: (...args: any[]) => { if (isDev) console.warn(...args); },
|
||||
error: (...args: any[]) => { console.error(...args); },
|
||||
info: (...args: any[]) => { if (isDev) console.info(...args); },
|
||||
};
|
||||
|
||||
export const sanitizeUrl = (url: string): string => {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
if (parsed.searchParams.has('token')) {
|
||||
parsed.searchParams.set('token', '***');
|
||||
}
|
||||
return parsed.pathname + (parsed.search ? parsed.search : '');
|
||||
} catch {
|
||||
return url.split('?')[0];
|
||||
}
|
||||
};
|
||||
@@ -102,10 +102,12 @@ class MediaCache {
|
||||
this.evictOldest();
|
||||
}
|
||||
|
||||
console.log(`[MediaCache] Cached ${mediaType}:`, url,
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`[MediaCache] Cached ${mediaType}:`, url.split('?')[0],
|
||||
sceneId ? `(scene: ${sceneId})` : '',
|
||||
projectId ? `(project: ${projectId})` : '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL is cached (with optional scene context)
|
||||
|
||||
46
temp_state.md
Normal file
46
temp_state.md
Normal file
@@ -0,0 +1,46 @@
|
||||
## Session Continuity
|
||||
|
||||
Last session:
|
||||
Stopped at: Session resumed, proceeding to discuss Phase 2 context
|
||||
Resume file: [updated if applicable]
|
||||
# Project State
|
||||
|
||||
## Project Reference
|
||||
**Core Value**: ALwrity is an AI-powered content creation platform that helps users generate various types of content including podcasts, videos, blogs, and social media content.
|
||||
|
||||
**Current Focus**: Based on recent development activity, the team is implementing Phase 2 of the WaveSpeed AI integration roadmap - Hyper-Personalization features for the Persona system, including voice training and avatar creation.
|
||||
|
||||
## Current Position
|
||||
**Phase**: 2 of 3 - Hyper-Personalization
|
||||
**Plan**: 3 of 5 - Persona Avatar Creation & Integration
|
||||
**Status**: In Progress - Working on avatar service implementation and frontend UI for avatar creation
|
||||
|
||||
## Progress
|
||||
Progress: [███████░░] 70%
|
||||
|
||||
## Recent Decisions
|
||||
1. **Avatar Service Architecture**: Decided to create a shared avatar service in backend/services/wavespeed/avatar/ for reuse across LinkedIn and Persona modules
|
||||
2. **UI Framework**: Continuing with Material-UI (MUI) for consistent avatar creation interface
|
||||
3. **Storage Strategy**: Using cloud storage for avatar assets with metadata tracking in PostgreSQL
|
||||
4. **Generation Queue**: Implementing asynchronous processing for avatar generation to prevent API timeouts
|
||||
|
||||
## Pending Todos
|
||||
- [ ] Complete avatar generation API endpoints
|
||||
- [ ] Implement avatar library management UI
|
||||
- [ ] Add avatar preview functionality
|
||||
- [ ] Create avatar upload/download capabilities
|
||||
- [ ] Integrate avatar selection into Persona dashboard
|
||||
- [ ] Add usage tracking and cost estimation for avatar generation
|
||||
- [ ] Write comprehensive tests for avatar service
|
||||
- [ ] Update documentation for avatar feature
|
||||
|
||||
## Blockers/Concerns
|
||||
- **WaveSpeed API Rate Limits**: Need to implement proper queuing and retry mechanisms
|
||||
- **Storage Costs**: Avatar storage could become expensive at scale - need to implement cleanup policies
|
||||
- **Generation Time**: Avatar generation can take 30-60 seconds - need to improve user experience during wait
|
||||
- **Quality Consistency**: Ensuring generated avatars maintain consistent quality across different inputs
|
||||
|
||||
## Session Continuity
|
||||
Last session: 2026-04-21 06:57:00
|
||||
Stopped at: Session resumed, proceeding to planning Phase 2 avatar creation work
|
||||
Resume file: .planning/phases/02-persona-hyper-personalization/03-avatar-creation/CONTINUE-HERE.md
|
||||
Reference in New Issue
Block a user