AI podcast maker performance optimizations

This commit is contained in:
ajaysi
2025-12-12 21:43:09 +05:30
parent 81590cf4db
commit eba5210577
46 changed files with 6176 additions and 1648 deletions

6
.gitignore vendored
View File

@@ -9,6 +9,12 @@ __pycache__/
backend/.onboarding_progress.json backend/.onboarding_progress.json
backend/database/migrations/* backend/database/migrations/*
*.mp3
podcast_audio/*
backend/podcast_audio/
.cursorignore .cursorignore
story_videos story_videos
story_videos/* story_videos/*

View File

@@ -1,19 +1,67 @@
""" """
Frontend Serving Module Frontend Serving Module
Handles React frontend serving and static file mounting. Handles React frontend serving and static file mounting with cache headers.
""" """
import os import os
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, Response
from starlette.middleware.base import BaseHTTPMiddleware
from loguru import logger from loguru import logger
from typing import Dict, Any from typing import Dict, Any
class CacheHeadersMiddleware(BaseHTTPMiddleware):
"""
Middleware to add cache headers to static files.
This improves performance by allowing browsers to cache static assets
(JS, CSS, images) for 1 year, reducing repeat visit load times.
"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Only add cache headers to static files
if request.url.path.startswith("/static/"):
path = request.url.path.lower()
# Check if file has a hash in its name (React build pattern: filename.hash.ext)
# Examples: bundle.abc123.js, main.def456.chunk.js, vendors.789abc.js
import re
# Pattern matches: filename.hexhash.ext or filename.hexhash.chunk.ext
hash_pattern = r'\.[a-f0-9]{8,}\.'
has_hash = bool(re.search(hash_pattern, path))
# File extensions that should be cached
cacheable_extensions = ['.js', '.css', '.woff', '.woff2', '.ttf', '.otf',
'.png', '.jpg', '.jpeg', '.webp', '.svg', '.ico', '.gif']
is_cacheable_file = any(path.endswith(ext) for ext in cacheable_extensions)
if is_cacheable_file:
if has_hash:
# Immutable files (with hash) - cache for 1 year
# These files never change (new hash = new file)
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
response.headers["Expires"] = "Thu, 31 Dec 2025 23:59:59 GMT"
else:
# Non-hashed files - shorter cache (1 hour)
# These might be updated, so cache for shorter time
response.headers["Cache-Control"] = "public, max-age=3600"
# Never cache HTML files (index.html)
elif request.url.path == "/" or request.url.path.endswith(".html"):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
class FrontendServing: class FrontendServing:
"""Manages React frontend serving and static file mounting.""" """Manages React frontend serving and static file mounting with cache headers."""
def __init__(self, app: FastAPI): def __init__(self, app: FastAPI):
self.app = app self.app = app
@@ -21,14 +69,26 @@ class FrontendServing:
self.static_path = os.path.join(self.frontend_build_path, "static") self.static_path = os.path.join(self.frontend_build_path, "static")
def setup_frontend_serving(self) -> bool: def setup_frontend_serving(self) -> bool:
"""Set up React frontend serving and static file mounting.""" """
Set up React frontend serving and static file mounting with cache headers.
This method:
1. Adds cache headers middleware for static files
2. Mounts static files directory
3. Configures proper caching for performance
"""
try: try:
logger.info("Setting up frontend serving...") logger.info("Setting up frontend serving with cache headers...")
# Add cache headers middleware BEFORE mounting static files
self.app.add_middleware(CacheHeadersMiddleware)
logger.info("Cache headers middleware added")
# Mount static files for React app (only if directory exists) # Mount static files for React app (only if directory exists)
if os.path.exists(self.static_path): if os.path.exists(self.static_path):
self.app.mount("/static", StaticFiles(directory=self.static_path), name="static") self.app.mount("/static", StaticFiles(directory=self.static_path), name="static")
logger.info("Frontend static files mounted successfully") logger.info("Frontend static files mounted successfully with cache headers")
logger.info("Static files will be cached for 1 year (immutable files) or 1 hour (others)")
return True return True
else: else:
logger.info("Frontend build directory not found. Static files not mounted.") logger.info("Frontend build directory not found. Static files not mounted.")
@@ -39,13 +99,23 @@ class FrontendServing:
return False return False
def serve_frontend(self) -> FileResponse | Dict[str, Any]: def serve_frontend(self) -> FileResponse | Dict[str, Any]:
"""Serve the React frontend.""" """
Serve the React frontend index.html.
Note: index.html is never cached to ensure users always get the latest version.
Static assets (JS/CSS) are cached separately via middleware.
"""
try: try:
# Check if frontend build exists # Check if frontend build exists
index_html = os.path.join(self.frontend_build_path, "index.html") index_html = os.path.join(self.frontend_build_path, "index.html")
if os.path.exists(index_html): if os.path.exists(index_html):
return FileResponse(index_html) # Return FileResponse with no-cache headers for HTML
response = FileResponse(index_html)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
else: else:
return { return {
"message": "Frontend not built. Please run 'npm run build' in the frontend directory.", "message": "Frontend not built. Please run 'npm run build' in the frontend directory.",

File diff suppressed because it is too large Load Diff

View File

@@ -300,6 +300,7 @@ class StoryAudioGenerationService:
volume: float = 1.0, volume: float = 1.0,
pitch: float = 0.0, pitch: float = 0.0,
emotion: str = "happy", emotion: str = "happy",
english_normalization: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Generate AI audio for a single scene using main_audio_generation. Generate AI audio for a single scene using main_audio_generation.
@@ -314,6 +315,7 @@ class StoryAudioGenerationService:
volume (float): Speech volume (0.1-10.0, default: 1.0). volume (float): Speech volume (0.1-10.0, default: 1.0).
pitch (float): Speech pitch (-12 to 12, default: 0.0). pitch (float): Speech pitch (-12 to 12, default: 0.0).
emotion (str): Emotion for speech (default: "happy"). emotion (str): Emotion for speech (default: "happy").
english_normalization (bool): Enable English text normalization for better number reading (default: False).
Returns: Returns:
Dict[str, Any]: Audio metadata including file path, URL, and scene info. Dict[str, Any]: Audio metadata including file path, URL, and scene info.
@@ -337,6 +339,7 @@ class StoryAudioGenerationService:
pitch=pitch, pitch=pitch,
emotion=emotion, emotion=emotion,
user_id=user_id, user_id=user_id,
english_normalization=english_normalization,
) )
# Save audio to file # Save audio to file

View File

@@ -0,0 +1,197 @@
# Build Optimization Guide
This guide explains how to optimize the production build for better performance.
## Current Issues
1. **Minify JavaScript**: 504 KiB savings possible
2. **Reduce unused JavaScript**: 980 KiB savings possible
3. **Minify CSS**: 24 KiB savings possible
4. **Reduce unused CSS**: 25 KiB savings possible
5. **Cache Headers**: 1,702 KiB not cached (requires server configuration)
## React Scripts Build Configuration
React Scripts already minifies JavaScript and CSS in production builds. However, you can optimize further:
### 1. Environment Variables
Create `.env.production` (already created) with:
```env
GENERATE_SOURCEMAP=false
INLINE_RUNTIME_CHUNK=false
```
### 2. Build Command
Run production build:
```bash
npm run build
```
This will:
- Minify JavaScript (already enabled)
- Minify CSS (already enabled)
- Tree-shake unused code (already enabled)
- Generate source maps (disabled via env var)
## Reducing Unused JavaScript
### Analyze Bundle Size
Install webpack-bundle-analyzer:
```bash
npm install --save-dev webpack-bundle-analyzer
```
Add to `package.json` scripts:
```json
"analyze": "npm run build && npx webpack-bundle-analyzer build/static/js/*.js"
```
Run:
```bash
npm run analyze
```
### Common Issues and Solutions
1. **Large Dependencies**:
- `framer-motion`: 246 KiB - Consider lazy loading animations
- `@mui/material`: Multiple chunks - Already code-split
- `recharts`: Only load when needed
2. **Unused Imports**:
- Use ESLint rule: `"no-unused-vars": "error"`
- Run: `npx eslint --ext .ts,.tsx src/ --fix`
3. **Dynamic Imports**:
- Already implemented for routes
- Consider lazy loading heavy components like charts
## Server-Side Cache Headers
### For Express.js (if using)
```javascript
// Add to your Express server
app.use(express.static('build', {
maxAge: '1y',
immutable: true,
etag: true,
lastModified: true
}));
```
### For Nginx
```nginx
location /static {
alias /path/to/build/static;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
```
### For Apache
```apache
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType text/css "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
</IfModule>
```
## Image Optimization
### Convert AskAlwrity-min.ico to WebP
1. Install sharp or use online tool:
```bash
npm install --save-dev sharp
```
2. Create script `scripts/optimize-images.js`:
```javascript
const sharp = require('sharp');
const path = require('path');
sharp('public/AskAlwrity-min.ico')
.resize(60, 60)
.webp({ quality: 80 })
.toFile('public/AskAlwrity-min.webp')
.then(() => console.log('Image optimized!'));
```
3. Update `index.html`:
```html
<link rel="icon" href="%PUBLIC_URL%/AskAlwrity-min.webp" />
```
## Performance Budget
Set performance budgets in `package.json`:
```json
{
"performance": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "50kb",
"maximumError": "100kb"
}
]
}
}
```
## Monitoring
### Lighthouse CI
Add to CI/CD pipeline:
```bash
npm install -g @lhci/cli
lhci autorun
```
### Web Vitals
Monitor in production:
```javascript
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
// Send to your analytics service
console.log(metric);
}
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getFCP(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);
```
## Expected Improvements
After implementing all optimizations:
- **Performance Score**: 28 → 70-80+
- **Bundle Size**: Reduced by ~1.5MB (unused code + minification)
- **Cache Hit Rate**: 0% → 90%+ (with proper headers)
- **CLS**: 0.167 → <0.1 (with layout fixes)
- **LCP**: Improved by additional 200-300ms

View File

@@ -0,0 +1,114 @@
# Unused JavaScript Optimization - Progress Tracker
## ✅ Completed
1. **Bundle Analysis Setup**
- Added `source-map-explorer` to devDependencies
- Added `npm run analyze` script
- Created analysis guide
2. **Lazy Loading Infrastructure**
- ✅ Created `frontend/src/utils/lazyRecharts.tsx` - Lazy load recharts
- ✅ Created `frontend/src/utils/lazyWix.ts` - Lazy load Wix SDK
- ✅ Updated `frontend/src/components/billing/UsageTrends.tsx`:
- Replaced direct recharts imports with lazy versions
- Replaced lucide-react icons with MUI icons
- Added Suspense boundaries
## 📋 Remaining Tasks
### High Priority (Large Impact)
1. **Update Other Chart Components**
- [ ] `frontend/src/components/SchedulerDashboard/SchedulerCharts.tsx`
- [ ] `frontend/src/components/ContentPlanningDashboard/components/MonitoringCharts.tsx`
- [ ] `frontend/src/components/shared/charts/AdvancedChartComponents.tsx`
2. **Lazy Load Wix SDK**
- [ ] `frontend/src/components/WixTestPage/WixTestPage.tsx`
- [ ] `frontend/src/components/WixCallbackPage/WixCallbackPage.tsx`
- [ ] `frontend/src/components/OnboardingWizard/common/usePlatformConnections.ts`
### Medium Priority
3. **Replace Lucide Icons with MUI Icons**
- [ ] `frontend/src/components/billing/EnhancedBillingDashboard.tsx`
- [ ] `frontend/src/components/billing/CompactBillingDashboard.tsx`
- [ ] `frontend/src/components/billing/BillingOverview.tsx`
- [ ] Other billing components using lucide-react
4. **Optimize Framer Motion**
- Review usage and replace simple animations with CSS
- Lazy load for non-critical animations
### Low Priority
5. **Further Code Splitting**
- Lazy load heavy components within routes
- Split large components into smaller chunks
## 🎯 How to Continue
### Step 1: Run Bundle Analysis
```bash
cd frontend
npm install # Install source-map-explorer
npm run analyze
# Open bundle-report.html to see current state
```
### Step 2: Update Chart Components
Follow the pattern in `UsageTrends.tsx`:
```typescript
// Before
import { LineChart, Line } from 'recharts';
// After
import { LazyLineChart, Line, ChartLoadingFallback } from '../../utils/lazyRecharts';
import { Suspense } from 'react';
<Suspense fallback={<ChartLoadingFallback />}>
<LazyLineChart data={data}>
<Line />
</LazyLineChart>
</Suspense>
```
### Step 3: Replace Icons
```typescript
// Before
import { TrendingUp } from 'lucide-react';
<TrendingUp size={20} />
// After
import { TrendingUp as TrendingUpIcon } from '@mui/icons-material';
<TrendingUpIcon fontSize="small" />
```
### Step 4: Test
```bash
npm run build
npm run analyze # Check if bundle size decreased
```
## 📊 Expected Results
### Current
- Unused JavaScript: 980 KiB
- Recharts: ~200 KiB (loaded on every page)
- Wix SDK: ~100 KiB (loaded on every page)
### After All Optimizations
- Unused JavaScript: < 200 KiB (estimated)
- Recharts: Only loaded when charts are viewed
- Wix SDK: Only loaded on Wix-related pages
- Performance: 33 → 50-60+ (estimated)
## 📝 Notes
- Lazy loading adds a small delay when components first load
- Use Suspense boundaries with loading states
- Test all functionality after changes
- Monitor bundle size after each change

View File

@@ -0,0 +1,162 @@
# Performance Optimizations Applied
This document outlines all the performance optimizations implemented to improve Lighthouse scores and overall app performance.
## 1. Font Loading Optimization
### Changes Made:
- Added `preconnect` hints for Google Fonts in `index.html`
- Added `dns-prefetch` for faster DNS resolution
- Font loading already uses `font-display: swap` in `global.css`
### Impact:
- Reduces font loading time by ~330ms (LCP improvement)
- Prevents render-blocking font requests
## 2. Code Splitting
### Changes Made:
- Implemented `React.lazy()` for all route components in `App.tsx`
- Added `Suspense` boundaries with loading fallbacks
- Route-level code splitting reduces initial bundle size
### Impact:
- Reduces initial JavaScript bundle from ~3.4MB to smaller chunks
- Each route loads only when needed
- Estimated savings: ~2,474 KiB of unused JavaScript
## 3. Layout Shift (CLS) Fixes
### Changes Made:
- Changed `::after` and `::before` pseudo-elements from `absolute` to `fixed` positioning
- Added `will-change: transform` for animation optimization
- Added `overflow: hidden` to prevent layout shifts
- Added `minHeight` to WorkflowHeroSection and parent containers to reserve space
- Added `pointerEvents: 'none'` to pseudo-elements to prevent layout impact
- Fixed line-height and width constraints on typography elements
### Impact:
- Reduced CLS score from 0.634 to 0.167 (73% improvement)
- Further improvements expected with reserved space for hero section
- Prevents visual instability during page load
## 4. Component Memoization
### Changes Made:
- Added `useMemo` for expensive search computations in `MainDashboard`
- Added `useCallback` for event handlers to prevent unnecessary re-renders
- Optimized search debouncing logic
### Impact:
- Reduces unnecessary re-renders
- Improves main thread performance
- Reduces JavaScript execution time
## 5. Build Optimizations
### Changes Made:
- Created `.env.production` with optimization flags
- `GENERATE_SOURCEMAP=false` for smaller production builds
- `INLINE_RUNTIME_CHUNK=false` for better caching
### Impact:
- Smaller production bundle size
- Better browser caching
- Faster subsequent page loads
## 6. Resource Hints
### Changes Made:
- Added `preconnect` for Google Fonts
- Added `dns-prefetch` for external domains
- Added meta tags for better browser optimization
### Impact:
- Faster connection establishment
- Reduced latency for external resources
## Performance Progress
### Before Optimizations:
- **Performance Score**: 12
- **CLS**: 0.634
- **Bundle Size**: 3,435 KiB (single bundle)
- **Cache**: 0% (3,514 KiB not cached)
### After Initial Optimizations:
- **Performance Score**: 28 (133% improvement)
- **CLS**: 0.167 (73% improvement)
- **Bundle Size**: Code-split into multiple chunks
- **Cache**: Still needs server configuration
### Remaining Optimizations Needed
### 1. Image Optimization
- **Issue**: `AskAlwrity-min.ico` is 78.6 KiB but displayed at 60x60
- **Solution**:
- Convert to WebP format (saves ~68 KiB)
- Resize to actual display size (saves ~74 KiB)
- Use responsive images with `srcset`
### 2. Cache Headers
- **Issue**: No cache headers for static assets (3,514 KiB not cached)
- **Solution**: Configure server to add cache headers:
```
Cache-Control: public, max-age=31536000, immutable
```
For `bundle.js` and other static assets
### 3. Bundle Analysis
- **Issue**: Large bundle size (3,435 KiB for bundle.js)
- **Solution**:
- Analyze bundle with `webpack-bundle-analyzer`
- Remove unused dependencies
- Consider dynamic imports for heavy libraries
### 4. Third-Party Scripts
- **Issue**: Clerk and CopilotKit scripts add to main thread work
- **Solution**:
- Load third-party scripts asynchronously
- Defer non-critical scripts
- Consider loading Clerk after initial render
### 5. Long Tasks
- **Issue**: 20 long tasks found, longest 6,208ms
- **Solution**:
- Break up large computations
- Use `requestIdleCallback` for non-critical work
- Implement virtual scrolling for long lists
## Performance Monitoring
### Recommended Tools:
1. **Lighthouse CI**: Automate performance testing
2. **Web Vitals**: Monitor Core Web Vitals in production
3. **Bundle Analyzer**: Track bundle size over time
4. **React DevTools Profiler**: Identify slow components
### Target Metrics:
- **Performance Score**: 90+ (currently 12)
- **FCP**: < 1.8s
- **LCP**: < 2.5s
- **CLS**: < 0.1
- **TBT**: < 200ms
- **Bundle Size**: < 500 KiB initial load
## Next Steps
1. **Immediate**:
- Optimize images (WebP conversion)
- Configure server cache headers
- Run bundle analysis
2. **Short-term**:
- Implement virtual scrolling
- Optimize third-party script loading
- Add service worker for caching
3. **Long-term**:
- Consider migrating to Vite for faster builds
- Implement progressive web app features
- Add performance budgets to CI/CD

View File

@@ -0,0 +1,231 @@
# Unused JavaScript Optimization Guide
## Current Issue
Lighthouse reports **980 KiB of unused JavaScript**. This guide helps identify and fix it.
## Strategy
### 1. Bundle Analysis
First, analyze what's taking up space:
```bash
cd frontend
npm install # Install source-map-explorer if needed
npm run analyze
```
This creates `bundle-report.html` - open it in a browser to see:
- Which packages are largest
- Which files import them
- Unused code within packages
### 2. Lazy Load Heavy Dependencies
#### A. Recharts (Charts Library)
**Size**: ~200+ KiB
**Usage**: Only in billing, analytics, and scheduler dashboards
**Before**:
```typescript
import { LineChart, Line } from 'recharts';
```
**After**:
```typescript
import { LazyLineChart, Line } from '../../utils/lazyRecharts';
import { Suspense } from 'react';
<Suspense fallback={<ChartSkeleton />}>
<LazyLineChart>
<Line />
</LazyLineChart>
</Suspense>
```
**Files to update**:
- `frontend/src/components/billing/UsageTrends.tsx`
- `frontend/src/components/SchedulerDashboard/SchedulerCharts.tsx`
- `frontend/src/components/ContentPlanningDashboard/components/MonitoringCharts.tsx`
- `frontend/src/components/shared/charts/AdvancedChartComponents.tsx`
#### B. Wix SDK
**Size**: ~100+ KiB
**Usage**: Only in WixTestPage and WixCallbackPage
**Before**:
```typescript
import { createClient } from '@wix/sdk';
```
**After**:
```typescript
const { createClient } = await import('@wix/sdk');
// Or use lazy loading in component
```
**Files to update**:
- `frontend/src/components/WixTestPage/WixTestPage.tsx`
- `frontend/src/components/WixCallbackPage/WixCallbackPage.tsx`
- `frontend/src/components/OnboardingWizard/common/usePlatformConnections.ts`
#### C. Framer Motion (Animations)
**Size**: ~246 KiB
**Usage**: Used extensively but can be optimized
**Strategy**:
1. Use CSS animations for simple transitions
2. Lazy load framer-motion for non-critical animations
3. Use `will-change` CSS property instead of complex animations
**Example**:
```typescript
// Instead of complex framer-motion for simple fade
// Use CSS:
const fadeIn = {
animation: 'fadeIn 0.3s ease-in'
};
```
### 3. Tree Shaking Optimization
#### A. Material-UI Icons
**Issue**: Importing entire icon set
**Before**:
```typescript
import { TrendingUp, TrendingDown } from '@mui/icons-material';
```
**After** (already optimized, but verify):
```typescript
// React Scripts should tree-shake automatically
// But verify imports are specific
```
#### B. Lucide React Icons
**Issue**: Large icon library, some can be replaced with MUI icons
**Strategy**: Replace lucide-react icons with MUI icons where possible
**Before**:
```typescript
import { TrendingUp } from 'lucide-react';
```
**After**:
```typescript
import { TrendingUp } from '@mui/icons-material';
```
### 4. Remove Unused Dependencies
Check if these are actually used:
- `@wix/blog` - Only in WixTestPage
- `lucide-react` - Can be replaced with MUI icons in many places
- `zod` - Verify if all schemas are used
### 5. Code Splitting Improvements
#### A. Route-Level Splitting (Already Done ✅)
Routes are already lazy-loaded.
#### B. Component-Level Splitting
Lazy load heavy components within routes:
```typescript
// In MainDashboard.tsx
const EnhancedBillingDashboard = lazy(() =>
import('../billing/EnhancedBillingDashboard')
);
```
### 6. Dynamic Imports for Heavy Features
#### A. Charts
Only load charts when dashboard is viewed:
```typescript
const loadCharts = () => import('recharts');
```
#### B. Analytics
Only load analytics when analytics tab is opened:
```typescript
const loadAnalytics = () => import('./components/AnalyticsInsights');
```
## Implementation Steps
### Step 1: Analyze Bundle
```bash
npm run analyze
# Open bundle-report.html
```
### Step 2: Identify Large Dependencies
Look for:
- Packages > 50 KiB
- Packages used in < 3 places
- Packages that can be lazy-loaded
### Step 3: Lazy Load Heavy Dependencies
1. Create lazy wrappers (see `lazyRecharts.tsx`)
2. Update imports to use lazy versions
3. Add Suspense boundaries
### Step 4: Replace Icons
1. Find lucide-react imports
2. Replace with MUI icons where possible
3. Remove lucide-react if not needed
### Step 5: Test
```bash
npm run build
npm run analyze # Check if bundle size decreased
```
## Expected Results
### Before
- Unused JavaScript: 980 KiB
- Bundle size: Large initial load
### After
- Unused JavaScript: < 200 KiB (estimated)
- Bundle size: Reduced by ~500-700 KiB
- Performance: Improved initial load time
## Files to Update
### High Priority (Large Impact)
1.`frontend/src/utils/lazyRecharts.tsx` - Created
2.`frontend/src/utils/lazyWix.ts` - Created
3. `frontend/src/components/billing/UsageTrends.tsx` - Use lazy recharts
4. `frontend/src/components/SchedulerDashboard/SchedulerCharts.tsx` - Use lazy recharts
5. `frontend/src/components/WixTestPage/WixTestPage.tsx` - Use lazy Wix SDK
### Medium Priority
6. `frontend/src/components/ContentPlanningDashboard/components/MonitoringCharts.tsx`
7. `frontend/src/components/shared/charts/AdvancedChartComponents.tsx`
8. Replace lucide-react with MUI icons in billing components
### Low Priority (Optimization)
9. Optimize framer-motion usage
10. Further code splitting within components
## Monitoring
After changes, verify:
1. Bundle size decreased
2. Lighthouse "Reduce unused JavaScript" improved
3. No broken functionality
4. Charts still work (with loading states)
## Next Steps
1. Run `npm run analyze` to see current bundle
2. Update components to use lazy-loaded dependencies
3. Test functionality
4. Re-run Lighthouse audit

View File

@@ -35,7 +35,9 @@
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"analyze": "npm run build && npx source-map-explorer 'build/static/js/*.js' --html bundle-report.html",
"analyze:size": "npm run build && npx bundlesize"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@@ -56,7 +58,8 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"typescript": "^4.9.5" "typescript": "^4.9.5",
"source-map-explorer": "^2.5.2"
}, },
"proxy": "http://localhost:8000", "proxy": "http://localhost:8000",
"homepage": "/" "homepage": "/"

View File

@@ -9,6 +9,18 @@
name="description" name="description"
content="Alwrity - AI Content Creation Platform" content="Alwrity - AI Content Creation Platform"
/> />
<!-- Performance optimizations -->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="format-detection" content="telephone=no" />
<!-- Preconnect to Google Fonts for faster font loading -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://fonts.gstatic.com" />
<!-- Preconnect to Clerk for faster authentication -->
<link rel="dns-prefetch" href="https://clerk.accounts.dev" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Alwrity - AI Content Creation Platform</title> <title>Alwrity - AI Content Creation Platform</title>

View File

@@ -508,6 +508,7 @@ const ContentLifecyclePillars: React.FC = () => {
borderRadius: 2, borderRadius: 2,
mb: 4, mb: 4,
position: 'relative', // For hero section positioning position: 'relative', // For hero section positioning
minHeight: '200px', // Reserve space for hero section to prevent layout shift
}} }}
> >
<Container maxWidth="xl"> <Container maxWidth="xl">

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { import {
Box, Box,
Container, Container,
@@ -84,15 +84,17 @@ const MainDashboard: React.FC = () => {
initializeWorkflow(); initializeWorkflow();
}, [generateDailyWorkflow]); }, [generateDailyWorkflow]);
// Debug logging for workflow state // Debug logging for workflow state (only in development)
React.useEffect(() => { React.useEffect(() => {
console.log('Workflow Debug:', { if (process.env.NODE_ENV === 'development') {
currentWorkflow, console.log('Workflow Debug:', {
workflowProgress, currentWorkflow,
isWorkflowActive: currentWorkflow?.workflowStatus === 'in_progress', workflowProgress,
workflowStatus: currentWorkflow?.workflowStatus, isWorkflowActive: currentWorkflow?.workflowStatus === 'in_progress',
hasWorkflow: !!currentWorkflow workflowStatus: currentWorkflow?.workflowStatus,
}); hasWorkflow: !!currentWorkflow
});
}
}, [currentWorkflow, workflowProgress]); }, [currentWorkflow, workflowProgress]);
// State to track if we need to start a newly generated workflow // State to track if we need to start a newly generated workflow
@@ -166,42 +168,50 @@ const MainDashboard: React.FC = () => {
} }
}; };
const handleToolClick = (tool: Tool) => { const handleToolClick = useCallback((tool: Tool) => {
console.log('Navigating to tool:', tool.path); if (process.env.NODE_ENV === 'development') {
console.log('Navigating to tool:', tool.path);
}
if (tool.path) { if (tool.path) {
navigate(tool.path); navigate(tool.path);
return; return;
} }
showSnackbar(`Launching ${tool.name}...`, 'info'); showSnackbar(`Launching ${tool.name}...`, 'info');
}; }, [navigate, showSnackbar]);
// Handle category click to open modal // Handle category click to open modal
const handleCategoryClick = (categoryName: string | null, categoryData?: any) => { const handleCategoryClick = useCallback((categoryName: string | null, categoryData?: any) => {
setModalCategoryName(categoryName); setModalCategoryName(categoryName);
setModalCategory(categoryData); setModalCategory(categoryData);
setToolsModalOpen(true); setToolsModalOpen(true);
}; }, []);
// Memoize search results computation
const searchResultsMemo = useMemo(() => {
if (!searchQuery || searchQuery.length < 2) return [];
// Get all tools from all categories that match search
const allTools: Tool[] = [];
Object.values(toolCategories).forEach(category => {
if (category) {
const tools = getToolsForCategory(category, null);
allTools.push(...tools);
}
});
const queryLower = searchQuery.toLowerCase();
return allTools.filter(tool =>
tool.name.toLowerCase().includes(queryLower) ||
tool.description.toLowerCase().includes(queryLower) ||
tool.features.some(feature => feature.toLowerCase().includes(queryLower))
);
}, [searchQuery]);
// Handle search to show results in modal with debouncing // Handle search to show results in modal with debouncing
React.useEffect(() => { React.useEffect(() => {
if (searchQuery && searchQuery.length >= 2) { // Only search after 2+ characters if (searchQuery && searchQuery.length >= 2) {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
// Get all tools from all categories that match search setSearchResults(searchResultsMemo);
const allTools: Tool[] = [];
Object.values(toolCategories).forEach(category => {
if (category) {
const tools = getToolsForCategory(category, null);
allTools.push(...tools);
}
});
const filtered = allTools.filter(tool =>
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
tool.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
tool.features.some(feature => feature.toLowerCase().includes(searchQuery.toLowerCase()))
);
setSearchResults(filtered);
setModalCategoryName(null); setModalCategoryName(null);
setModalCategory(null); setModalCategory(null);
setToolsModalOpen(true); setToolsModalOpen(true);
@@ -212,10 +222,10 @@ const MainDashboard: React.FC = () => {
// Close modal if search query is too short // Close modal if search query is too short
setToolsModalOpen(false); setToolsModalOpen(false);
} }
}, [searchQuery]); }, [searchQuery, searchResultsMemo]);
// Close modal and clear search // Close modal and clear search
const handleCloseModal = () => { const handleCloseModal = useCallback(() => {
setToolsModalOpen(false); setToolsModalOpen(false);
setModalCategoryName(null); setModalCategoryName(null);
setModalCategory(null); setModalCategory(null);
@@ -223,7 +233,7 @@ const MainDashboard: React.FC = () => {
if (searchQuery) { if (searchQuery) {
setSearchQuery(''); setSearchQuery('');
} }
}; }, [searchQuery, setSearchQuery]);
// Note: filteredCategories removed as it's not used in the current implementation // Note: filteredCategories removed as it's not used in the current implementation
@@ -242,19 +252,21 @@ const MainDashboard: React.FC = () => {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
padding: theme.spacing(4), padding: theme.spacing(4),
position: 'relative', position: 'relative',
overflow: 'hidden', // Prevent layout shifts from pseudo-elements
'&::before': { '&::before': {
content: '""', content: '""',
position: 'absolute', position: 'fixed', // Changed from absolute to fixed to prevent layout shifts
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
background: 'url("data:image/svg+xml,%3Csvg width="80" height="80" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.03"%3E%3Ccircle cx="40" cy="40" r="3"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")', background: 'url("data:image/svg+xml,%3Csvg width="80" height="80" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.03"%3E%3Ccircle cx="40" cy="40" r="3"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")',
pointerEvents: 'none', pointerEvents: 'none',
willChange: 'transform', // Optimize for animations
}, },
'&::after': { '&::after': {
content: '""', content: '""',
position: 'absolute', position: 'fixed', // Changed from absolute to fixed to prevent layout shifts
top: '50%', top: '50%',
left: '50%', left: '50%',
width: '600px', width: '600px',
@@ -263,6 +275,7 @@ const MainDashboard: React.FC = () => {
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 0, zIndex: 0,
willChange: 'transform', // Optimize for animations
}, },
}} }}
> >

View File

@@ -55,6 +55,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
justifyContent: 'center', justifyContent: 'center',
borderRadius: 2, // Match the parent container's border radius borderRadius: 2, // Match the parent container's border radius
px: 2, // Add horizontal padding to constrain width px: 2, // Add horizontal padding to constrain width
minHeight: '200px', // Reserve space to prevent layout shift
willChange: 'transform', // Optimize for animations
}} }}
> >
{/* Hero Content - Full Coverage */} {/* Hero Content - Full Coverage */}
@@ -130,6 +132,7 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.2 }} transition={{ duration: 0.8, delay: 0.2 }}
style={{ width: '100%' }} // Prevent width changes
> >
{/* Main Heading with Rocket */} {/* Main Heading with Rocket */}
<Box sx={{ <Box sx={{
@@ -137,7 +140,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 2, gap: 2,
mb: 2 mb: 2,
minHeight: '48px', // Reserve space for heading to prevent layout shift
}}> }}>
<Typography <Typography
variant={isMobile ? "h5" : "h4"} variant={isMobile ? "h5" : "h4"}
@@ -149,6 +153,7 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
backgroundClip: 'text', backgroundClip: 'text',
WebkitBackgroundClip: 'text', WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent', WebkitTextFillColor: 'transparent',
lineHeight: 1.2, // Fixed line height to prevent shift
}} }}
> >
Grow Your Business Now Grow Your Business Now
@@ -220,6 +225,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent)', background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent)',
animation: 'shimmer 2.5s infinite', animation: 'shimmer 2.5s infinite',
zIndex: 1, zIndex: 1,
pointerEvents: 'none', // Prevent layout impact
willChange: 'left', // Optimize animation
}, },
'&::after': { '&::after': {
content: '""', content: '""',
@@ -233,6 +240,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
borderRadius: 'inherit', borderRadius: 'inherit',
zIndex: -1, zIndex: -1,
animation: 'borderGlow 3s ease-in-out infinite', animation: 'borderGlow 3s ease-in-out infinite',
pointerEvents: 'none', // Prevent layout impact
willChange: 'background-position', // Optimize animation
}, },
'@keyframes shimmer': { '@keyframes shimmer': {
'0%': { left: '-100%' }, '0%': { left: '-100%' },

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { Stack, Box, Typography, Divider, Chip, Paper, alpha } from "@mui/material"; import { Stack, Box, Typography, Divider, Chip, Paper, alpha } from "@mui/material";
import { Psychology as PsychologyIcon, Insights as InsightsIcon } from "@mui/icons-material"; import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon } from "@mui/icons-material";
import { PodcastAnalysis } from "./types"; import { PodcastAnalysis } from "./types";
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui"; import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
import { Refresh as RefreshIcon } from "@mui/icons-material"; import { Refresh as RefreshIcon } from "@mui/icons-material";
@@ -92,6 +92,82 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, onRegene
</Stack> </Stack>
<Stack spacing={2}> <Stack spacing={2}>
{analysis.exaSuggestedConfig && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", display: "flex", alignItems: "center", gap: 0.5 }}>
<SearchIcon fontSize="small" sx={{ color: "#4f46e5" }} />
Exa Research Suggestions
</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
{analysis.exaSuggestedConfig.exa_search_type && (
<Chip
label={`Search: ${analysis.exaSuggestedConfig.exa_search_type}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{analysis.exaSuggestedConfig.exa_category && (
<Chip
label={`Category: ${analysis.exaSuggestedConfig.exa_category}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{analysis.exaSuggestedConfig.date_range && (
<Chip
label={`Date: ${analysis.exaSuggestedConfig.date_range}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{typeof analysis.exaSuggestedConfig.include_statistics === "boolean" && (
<Chip
label={analysis.exaSuggestedConfig.include_statistics ? "Include stats" : "No stats needed"}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{analysis.exaSuggestedConfig.max_sources && (
<Chip
label={`Max sources: ${analysis.exaSuggestedConfig.max_sources}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
</Stack>
{(analysis.exaSuggestedConfig.exa_include_domains?.length || analysis.exaSuggestedConfig.exa_exclude_domains?.length) && (
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
{analysis.exaSuggestedConfig.exa_include_domains?.length ? (
<Box>
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
Prefer domains
</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{analysis.exaSuggestedConfig.exa_include_domains.map((d) => (
<Chip key={d} label={d} size="small" sx={{ background: "#f8fafc", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }} />
))}
</Stack>
</Box>
) : null}
{analysis.exaSuggestedConfig.exa_exclude_domains?.length ? (
<Box>
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
Avoid domains
</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{analysis.exaSuggestedConfig.exa_exclude_domains.map((d) => (
<Chip key={d} label={d} size="small" sx={{ background: "#fff7ed", color: "#b45309", border: "1px solid rgba(180,83,9,0.25)" }} />
))}
</Stack>
</Box>
) : null}
</Stack>
)}
</Box>
)}
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Suggested Episode Outlines</Typography> <Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Suggested Episode Outlines</Typography>
<Stack spacing={1.5}> <Stack spacing={1.5}>

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { Stack, Box, Typography, TextField, Divider, Button, Alert, alpha, Tooltip, Paper, Chip } from "@mui/material"; import { Stack, Box, Typography, TextField, Divider, Button, Alert, alpha, Tooltip, Paper, Chip, IconButton } from "@mui/material";
import { import {
AutoAwesome as AutoAwesomeIcon, AutoAwesome as AutoAwesomeIcon,
Refresh as RefreshIcon, Refresh as RefreshIcon,
Info as InfoIcon, Info as InfoIcon,
HelpOutline as HelpOutlineIcon,
AttachMoney as AttachMoneyIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { CreateProjectPayload, Knobs } from "./types"; import { CreateProjectPayload, Knobs } from "./types";
import { PrimaryButton, SecondaryButton } from "./ui"; import { PrimaryButton, SecondaryButton } from "./ui";
@@ -16,17 +18,25 @@ interface CreateModalProps {
isSubmitting?: boolean; isSubmitting?: boolean;
} }
// Rotating placeholder examples for topic ideas
const TOPIC_PLACEHOLDERS = [
"How AI is transforming content marketing in 2024",
"The future of remote work: trends and predictions",
"Sustainable business practices for modern companies",
];
export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false }) => { export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false }) => {
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const [idea, setIdea] = useState(""); const [idea, setIdea] = useState("");
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [showAIDetailsButton, setShowAIDetailsButton] = useState(false); const [showAIDetailsButton, setShowAIDetailsButton] = useState(false);
const [speakers, setSpeakers] = useState<number>(1); const [speakers, setSpeakers] = useState<number>(1);
const [duration, setDuration] = useState<number>(10); const [duration, setDuration] = useState<number>(1);
const [budgetCap, setBudgetCap] = useState<number>(50); const [budgetCap, setBudgetCap] = useState<number>(50);
const [voiceFile, setVoiceFile] = useState<File | null>(null); const [voiceFile, setVoiceFile] = useState<File | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null); const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs }); const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
const [placeholderIndex, setPlaceholderIndex] = useState(0);
// Determine subscription tier restrictions // Determine subscription tier restrictions
const tier = subscription?.tier || 'free'; const tier = subscription?.tier || 'free';
@@ -35,6 +45,16 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
const canUseHD = !isFreeTier && !isBasicTier; // HD only for pro/enterprise const canUseHD = !isFreeTier && !isBasicTier; // HD only for pro/enterprise
const canUseMultiSpeaker = !isFreeTier; // Multi-speaker for basic+ tiers const canUseMultiSpeaker = !isFreeTier; // Multi-speaker for basic+ tiers
// Rotate placeholder every 3 seconds
useEffect(() => {
if (!idea && !url) {
const interval = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % TOPIC_PLACEHOLDERS.length);
}, 3000);
return () => clearInterval(interval);
}
}, [idea, url]);
// Reset HD quality if user downgrades // Reset HD quality if user downgrades
useEffect(() => { useEffect(() => {
if (!canUseHD && knobs.bitrate === 'hd') { if (!canUseHD && knobs.bitrate === 'hd') {
@@ -49,11 +69,42 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
} }
}, [canUseMultiSpeaker]); }, [canUseMultiSpeaker]);
// Ensure duration and speakers are within limits
useEffect(() => {
if (duration > 10) {
setDuration(10);
}
if (speakers > 2) {
setSpeakers(2);
}
}, [duration, speakers]);
// Show AI details button when user starts typing // Show AI details button when user starts typing
useEffect(() => { useEffect(() => {
setShowAIDetailsButton(idea.trim().length > 0); setShowAIDetailsButton(idea.trim().length > 0);
}, [idea]); }, [idea]);
// Calculate estimated cost
const estimatedCost = useMemo(() => {
const chars = Math.max(1000, duration * 900); // ~900 chars per minute
const scenes = Math.ceil((duration * 60) / (knobs.scene_length_target || 45));
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]);
const canSubmit = Boolean(idea || url); const canSubmit = Boolean(idea || url);
const submit = () => { const submit = () => {
@@ -72,11 +123,22 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
setIdea(""); setIdea("");
setUrl(""); setUrl("");
setSpeakers(1); setSpeakers(1);
setDuration(10); setDuration(1);
setBudgetCap(50); setBudgetCap(50);
setVoiceFile(null); setVoiceFile(null);
setAvatarFile(null); setAvatarFile(null);
setKnobs({ ...defaultKnobs }); setKnobs({ ...defaultKnobs });
setPlaceholderIndex(0);
};
const handleDurationChange = (value: number) => {
const clamped = Math.min(10, Math.max(1, value));
setDuration(clamped);
};
const handleSpeakersChange = (value: number) => {
const clamped = Math.min(2, Math.max(1, value));
setSpeakers(clamped);
}; };
return ( return (
@@ -84,49 +146,227 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
elevation={0} elevation={0}
sx={{ sx={{
borderRadius: 3, borderRadius: 3,
border: "1px solid rgba(0,0,0,0.08)", border: "1px solid rgba(15, 23, 42, 0.08)",
background: "#ffffff", background: "#ffffff",
boxShadow: "0 6px 20px rgba(15, 23, 42, 0.08)", boxShadow: "0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.08)",
p: { xs: 3, md: 4 }, p: { xs: 3, md: 4.5 },
}} }}
> >
<Stack spacing={2}> <Stack spacing={3.5}>
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between" flexWrap="wrap"> {/* Header Section */}
<Stack direction="row" spacing={2} alignItems="center"> <Stack direction="row" spacing={2} alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={2}>
<AutoAwesomeIcon sx={{ color: "#667eea" }} /> <Stack direction="row" spacing={2} alignItems="flex-start" sx={{ flex: 1, minWidth: { xs: "100%", md: "60%" } }}>
<Box> <Box
<Typography variant="h5" sx={{ color: "#0f172a", fontWeight: 800 }}> sx={{
Create New Podcast Episode width: 48,
</Typography> height: 48,
<Typography variant="body2" color="text.secondary"> borderRadius: 2,
Provide either a topic idea or a blog post URL. We start AI analysis only after you click Analyze & Continue. background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
</Typography> display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<AutoAwesomeIcon sx={{ color: "#667eea", fontSize: "1.75rem" }} />
</Box>
<Box sx={{ flex: 1 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Typography
variant="h5"
sx={{
color: "#0f172a",
fontWeight: 700,
fontSize: { xs: "1.5rem", md: "1.75rem" },
letterSpacing: "-0.02em",
lineHeight: 1.2,
}}
>
Create New Podcast Episode
</Typography>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Tips for best results:
</Typography>
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem" }}>
Provide one clear topic OR a single blog URL (we won't auto-run anything).<br />
• Keep it concise—one sentence topic works best.<br />
• We start analysis only after you confirm, so you stay in control.
</Typography>
</Box>
}
arrow
placement="top"
componentsProps={{
tooltip: {
sx: {
bgcolor: "#0f172a",
color: "#ffffff",
maxWidth: 300,
fontSize: "0.875rem",
p: 1.5,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
},
},
arrow: {
sx: {
color: "#0f172a",
},
},
}}
>
<IconButton
size="small"
sx={{
color: "#64748b",
"&:hover": {
color: "#667eea",
backgroundColor: alpha("#667eea", 0.08),
}
}}
>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Box> </Box>
</Stack> </Stack>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ alignItems: "center" }}>
<Chip label={`Plan: ${subscription?.tier || "free"}`} size="small" color="default" /> <Tooltip
<Chip label={`Duration: ${duration} min`} size="small" color="default" /> title={`Your current subscription plan: ${subscription?.tier || "free"}. Upgrade for more features.`}
<Chip label={`${speakers} speaker${speakers > 1 ? "s" : ""}`} size="small" color="default" /> arrow
placement="top"
>
<Chip
label={`Plan: ${subscription?.tier || "free"}`}
size="small"
sx={{
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
color: "#667eea",
fontWeight: 600,
border: "1px solid rgba(102, 126, 234, 0.2)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
<Tooltip
title={`Podcast duration: ${duration} minutes. Maximum duration is 10 minutes. Recommended: 5-10 minutes for best results.`}
arrow
placement="top"
>
<Chip
label={`Duration: ${duration} min`}
size="small"
sx={{
background: alpha("#0f172a", 0.06),
color: "#0f172a",
fontWeight: 600,
border: "1px solid rgba(15, 23, 42, 0.12)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
<Tooltip
title={`Number of speakers: ${speakers}. Supports 1-2 speakers. Each additional speaker adds avatar generation cost.`}
arrow
placement="top"
>
<Chip
label={`${speakers} speaker${speakers > 1 ? "s" : ""}`}
size="small"
sx={{
background: alpha("#0f172a", 0.06),
color: "#0f172a",
fontWeight: 600,
border: "1px solid rgba(15, 23, 42, 0.12)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Estimated Cost Breakdown:
</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}
</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>
</Typography>
</Box>
}
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}`}
size="small"
sx={{
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)",
color: "#059669",
fontWeight: 600,
border: "1px solid rgba(16, 185, 129, 0.2)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
</Stack> </Stack>
</Stack> </Stack>
<Alert severity="info" sx={{ background: "#eef2ff", border: "1px solid #e0e7ff" }}>
<Typography variant="body2" sx={{ color: "#4338ca" }}>
Tips for best results:
</Typography>
<Typography variant="body2" sx={{ color: "#4338ca" }}>
Provide one clear topic OR a single blog URL (we wont auto-run anything).<br />
Keep it conciseone sentence topic works best.<br />
We start analysis only after you confirm, so you stay in control.
</Typography>
</Alert>
<Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="stretch"> {/* Input Section */}
{/* Topic Idea Section */} <Box
<Box flex={1}> sx={{
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}> p: 3,
Topic Idea borderRadius: 2,
</Typography> background: alpha("#f8fafc", 0.5),
border: "1px solid rgba(15, 23, 42, 0.06)",
}}
>
<Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="stretch">
{/* Topic Idea Section */}
<Box flex={1}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
Topic Idea
</Typography>
</Stack>
<Tooltip <Tooltip
title="Enter a concise idea. We will expand it into an outline only after you click Analyze." title="Enter a concise idea. We will expand it into an outline only after you click Analyze."
arrow arrow
@@ -136,7 +376,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
fullWidth fullWidth
multiline multiline
rows={5} rows={5}
placeholder="e.g., 'How AI is transforming content marketing in 2024'" placeholder={!idea && !url ? `e.g., "${TOPIC_PLACEHOLDERS[placeholderIndex]}"` : ""}
inputProps={{ inputProps={{
sx: { sx: {
"&::placeholder": { color: "#94a3b8", opacity: 1 }, "&::placeholder": { color: "#94a3b8", opacity: 1 },
@@ -152,25 +392,38 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
} }
}} }}
size="small" size="small"
helperText="We will not start analysis until you click Analyze." helperText="Enter a clear, concise topic. We'll expand it into a full script after you click Analyze."
sx={{ sx={{
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc", backgroundColor: "#ffffff",
border: "1.5px solid rgba(15, 23, 42, 0.12)",
borderRadius: 2,
"&:hover": { "&:hover": {
backgroundColor: "#f1f5f9", backgroundColor: "#ffffff",
borderColor: "rgba(102, 126, 234, 0.4)",
},
"&.Mui-focused": {
backgroundColor: "#ffffff",
borderColor: "#667eea",
borderWidth: 2,
}, },
"& .MuiOutlinedInput-input": { "& .MuiOutlinedInput-input": {
fontSize: "0.95rem", fontSize: "0.9375rem",
lineHeight: 1.5, lineHeight: 1.6,
color: "#0f172a", color: "#0f172a",
fontWeight: 400,
}, },
}, },
"& .MuiInputBase-input::placeholder": { "& .MuiInputBase-input::placeholder": {
color: "#94a3b8", color: "#94a3b8",
opacity: 1, opacity: 1,
fontWeight: 400,
}, },
"& .MuiFormHelperText-root": { "& .MuiFormHelperText-root": {
color: "#475569", color: "#64748b",
fontSize: "0.8125rem",
fontWeight: 400,
mt: 0.75,
}, },
}} }}
/> />
@@ -189,8 +442,11 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
sx={{ sx={{
textTransform: "none", textTransform: "none",
fontSize: "0.875rem", fontSize: "0.875rem",
fontWeight: 600,
borderColor: "#667eea", borderColor: "#667eea",
borderWidth: 1.5,
color: "#667eea", color: "#667eea",
borderRadius: 2,
"&:hover": { "&:hover": {
borderColor: "#5568d3", borderColor: "#5568d3",
backgroundColor: alpha("#667eea", 0.08), backgroundColor: alpha("#667eea", 0.08),
@@ -203,20 +459,67 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
)} )}
</Box> </Box>
{/* Center OR divider */} {/* Center OR divider */}
<Stack alignItems="center" justifyContent="center" sx={{ px: { xs: 0, md: 1 } }}> <Stack alignItems="center" justifyContent="center" sx={{ px: { xs: 0, md: 2 } }}>
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", md: "block" }, borderColor: "rgba(0,0,0,0.08)" }} /> <Divider
<Divider sx={{ display: { xs: "block", md: "none" }, borderColor: "rgba(0,0,0,0.08)", my: 1 }} /> orientation="vertical"
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 700, fontSize: "0.75rem" }}> flexItem
OR sx={{
</Typography> display: { xs: "none", md: "block" },
</Stack> borderColor: "rgba(15, 23, 42, 0.1)",
borderWidth: 1,
}}
/>
<Box
sx={{
display: { xs: "flex", md: "none" },
alignItems: "center",
width: "100%",
my: 2,
}}
>
<Divider sx={{ flex: 1, borderColor: "rgba(15, 23, 42, 0.1)" }} />
<Box
sx={{
px: 2,
py: 0.5,
borderRadius: 2,
background: alpha("#ffffff", 0.8),
border: "1px solid rgba(15, 23, 42, 0.1)",
}}
>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 700, fontSize: "0.75rem" }}>
OR
</Typography>
</Box>
<Divider sx={{ flex: 1, borderColor: "rgba(15, 23, 42, 0.1)" }} />
</Box>
<Box
sx={{
display: { xs: "none", md: "flex" },
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: "50%",
background: alpha("#ffffff", 0.9),
border: "1px solid rgba(15, 23, 42, 0.1)",
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
}}
>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 700, fontSize: "0.75rem" }}>
OR
</Typography>
</Box>
</Stack>
{/* Blog URL Section */} {/* Blog URL Section */}
<Box flex={1}> <Box flex={1}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}> <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
Blog Post URL <Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
</Typography> Blog Post URL
</Typography>
</Stack>
<Tooltip <Tooltip
title="Paste a single article URL. Well fetch insights only after you click Analyze." title="Paste a single article URL. Well fetch insights only after you click Analyze."
arrow arrow
@@ -252,63 +555,246 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
}} }}
sx={{ sx={{
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc", backgroundColor: "#ffffff",
border: "1.5px solid rgba(15, 23, 42, 0.12)",
borderRadius: 2,
"&:hover": { "&:hover": {
backgroundColor: "#f1f5f9", backgroundColor: "#ffffff",
borderColor: "rgba(102, 126, 234, 0.4)",
},
"&.Mui-focused": {
backgroundColor: "#ffffff",
borderColor: "#667eea",
borderWidth: 2,
},
},
"& .MuiInputBase-input": {
color: "#0f172a",
fontSize: "0.9375rem",
fontWeight: 400,
},
"& .MuiInputLabel-root": {
color: "#64748b",
fontSize: "0.9375rem",
"&.Mui-focused": {
color: "#667eea",
}, },
}, },
"& .MuiInputBase-input::placeholder": { "& .MuiInputBase-input::placeholder": {
color: "#94a3b8", color: "#94a3b8",
opacity: 1, opacity: 1,
fontWeight: 400,
}, },
"& .MuiFormHelperText-root": { "& .MuiFormHelperText-root": {
color: "#475569", color: "#64748b",
fontSize: "0.8125rem",
fontWeight: 400,
mt: 0.75,
}, },
}} }}
/> />
</Tooltip> </Tooltip>
</Box> </Box>
</Stack> </Stack>
</Box>
{/* Quick settings for duration and speakers */} {/* Settings Section */}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}> <Box
<TextField sx={{
label="Duration (minutes)" p: 3,
type="number" borderRadius: 2,
value={duration} background: alpha("#f8fafc", 0.5),
onChange={(e) => setDuration(Math.max(1, Number(e.target.value) || 0))} border: "1px solid rgba(15, 23, 42, 0.06)",
InputProps={{ inputProps: { min: 1, max: 60 } }} }}
size="small" >
helperText="Typical podcasts: 5-20 minutes" <Typography variant="subtitle2" sx={{ mb: 2, color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
sx={{ Podcast Settings
maxWidth: 220, </Typography>
"& .MuiOutlinedInput-root": { <Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="flex-start">
backgroundColor: "#f8fafc", <Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ flex: 1 }}>
"&:hover": { backgroundColor: "#f1f5f9" }, <TextField
}, label="Duration (minutes)"
}} type="number"
/> value={duration}
<TextField onChange={(e) => handleDurationChange(Number(e.target.value) || 1)}
label="Number of speakers" InputProps={{ inputProps: { min: 1, max: 10 } }}
type="number" size="small"
value={speakers} helperText={duration > 10 ? "Maximum duration is 10 minutes" : `Recommended: 1-3 minutes for quick tests (currently: ${duration} min)`}
onChange={(e) => setSpeakers(Math.min(4, Math.max(1, Number(e.target.value) || 1)))} error={duration > 10}
InputProps={{ inputProps: { min: 1, max: 4 } }} sx={{
size="small" maxWidth: 220,
helperText="Supports single or panel style" "& .MuiOutlinedInput-root": {
sx={{ backgroundColor: "#ffffff",
maxWidth: 220, border: "1.5px solid rgba(15, 23, 42, 0.12)",
"& .MuiOutlinedInput-root": { borderRadius: 2,
backgroundColor: "#f8fafc", "&:hover": {
"&:hover": { backgroundColor: "#f1f5f9" }, backgroundColor: "#ffffff",
}, borderColor: "rgba(102, 126, 234, 0.4)",
}} },
/> "&.Mui-focused": {
</Stack> borderColor: "#667eea",
borderWidth: 2,
},
},
"& .MuiInputLabel-root": {
color: "#64748b",
"&.Mui-focused": {
color: "#667eea",
},
},
"& .MuiFormHelperText-root": {
color: "#64748b",
fontSize: "0.8125rem",
},
}}
/>
<TextField
label="Number of speakers"
type="number"
value={speakers}
onChange={(e) => handleSpeakersChange(Number(e.target.value) || 1)}
InputProps={{ inputProps: { min: 1, max: 2 } }}
size="small"
helperText={speakers > 2 ? "Maximum 2 speakers supported" : `Supports 1-2 speakers (currently: ${speakers})`}
error={speakers > 2}
sx={{
maxWidth: 220,
"& .MuiOutlinedInput-root": {
backgroundColor: "#ffffff",
border: "1.5px solid rgba(15, 23, 42, 0.12)",
borderRadius: 2,
"&:hover": {
backgroundColor: "#ffffff",
borderColor: "rgba(102, 126, 234, 0.4)",
},
"&.Mui-focused": {
borderColor: "#667eea",
borderWidth: 2,
},
},
"& .MuiInputLabel-root": {
color: "#64748b",
"&.Mui-focused": {
color: "#667eea",
},
},
"& .MuiFormHelperText-root": {
color: "#64748b",
fontSize: "0.8125rem",
},
}}
/>
</Stack>
{/* Cost Breakdown Panel - positioned in empty space */}
<Paper
elevation={0}
sx={{
p: 2.5,
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.08) 100%)",
border: "1.5px solid rgba(16, 185, 129, 0.2)",
borderRadius: 2,
minWidth: { xs: "100%", sm: 300 },
flex: { xs: "none", sm: "0 0 auto" },
boxShadow: "0 2px 8px rgba(16, 185, 129, 0.08)",
}}
>
<Stack spacing={1.5}>
<Stack direction="row" spacing={1} alignItems="center">
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1.5,
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<AttachMoneyIcon sx={{ fontSize: "1.125rem", color: "#059669" }} />
</Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.875rem" }}>
Estimated Cost
</Typography>
</Stack>
<Typography
variant="h5"
sx={{
color: "#059669",
fontWeight: 700,
fontSize: "1.75rem",
lineHeight: 1.2,
}}
>
${estimatedCost.total}
</Typography>
<Stack spacing={0.75} sx={{ mt: 0.5 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
Audio Generation
</Typography>
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
${estimatedCost.ttsCost}
</Typography>
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
Avatar Creation
</Typography>
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
${estimatedCost.avatarCost}
</Typography>
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
Video Rendering
</Typography>
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
${estimatedCost.videoCost}
</Typography>
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
Research
</Typography>
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
${estimatedCost.researchCost}
</Typography>
</Box>
</Stack>
<Box
sx={{
mt: 1,
pt: 1.5,
borderTop: "1.5px solid rgba(16, 185, 129, 0.15)",
}}
>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.75rem", fontWeight: 500 }}>
{duration} min {speakers} speaker{speakers > 1 ? "s" : ""} {knobs.bitrate === "hd" ? "HD" : "Standard"} quality
</Typography>
</Box>
</Stack>
</Paper>
</Stack>
</Box>
<Alert severity="info" sx={{ background: "#ecfeff", border: "1px solid #bae6fd", borderRadius: 1 }}> {/* Info Banner */}
<Typography variant="body2" sx={{ fontSize: "0.9rem", color: "#0ea5e9" }}> <Alert
You can provide either a topic idea or a blog post URL. We wont make any external AI calls until you click Analyze & Continue. severity="info"
icon={<InfoIcon sx={{ color: "#6366f1", fontSize: "1.125rem" }} />}
sx={{
background: alpha("#f0f4ff", 0.6),
border: "1px solid rgba(99, 102, 241, 0.15)",
borderRadius: 2,
boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)",
"& .MuiAlert-message": {
width: "100%",
},
}}
>
<Typography variant="body2" sx={{ fontSize: "0.875rem", color: "#475569", lineHeight: 1.6, fontWeight: 400 }}>
You can provide either a topic idea or a blog post URL. We won't make any external AI calls until you click "Analyze & Continue".
</Typography> </Typography>
</Alert> </Alert>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Paper, Stack, Typography, IconButton, Tooltip, alpha } from "@mui/material"; import { Paper, Stack, Typography, IconButton, Tooltip, alpha, Alert } from "@mui/material";
import { VolumeUp as VolumeUpIcon, PlayCircle as PlayCircleIcon, PauseCircle as PauseCircleIcon, Download as DownloadIcon } from "@mui/icons-material"; import { VolumeUp as VolumeUpIcon, PlayCircle as PlayCircleIcon, PauseCircle as PauseCircleIcon, Download as DownloadIcon } from "@mui/icons-material";
import { aiApiClient } from "../../api/client";
interface InlineAudioPlayerProps { interface InlineAudioPlayerProps {
audioUrl: string; audioUrl: string;
@@ -11,37 +12,140 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const audioRef = React.useRef<HTMLAudioElement>(null); const audioRef = React.useRef<HTMLAudioElement>(null);
// Load audio as blob if it's an authenticated endpoint
useEffect(() => {
if (!audioUrl) {
setBlobUrl(null);
setError(null);
return;
}
// Check if this is a podcast audio endpoint that requires authentication
const isPodcastAudio = audioUrl.includes('/api/podcast/audio/') || audioUrl.includes('/api/story/audio/');
if (!isPodcastAudio) {
// Regular URL, use directly
setBlobUrl(audioUrl);
setError(null);
return;
}
// Fetch as blob for authenticated endpoints
let isMounted = true;
const currentAudioUrl = audioUrl;
const loadAudioBlob = async () => {
try {
// Normalize path
let audioPath = currentAudioUrl.startsWith('/') ? currentAudioUrl : `/${currentAudioUrl}`;
// Convert /api/story/audio/ to /api/podcast/audio/ if needed
if (audioPath.includes('/api/story/audio/')) {
const filename = audioPath.split('/api/story/audio/').pop() || '';
audioPath = `/api/podcast/audio/${filename}`;
}
// Ensure it's a podcast audio endpoint
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || currentAudioUrl;
audioPath = `/api/podcast/audio/${filename}`;
}
// Remove query parameters if present
audioPath = audioPath.split('?')[0];
const response = await aiApiClient.get(audioPath, {
responseType: 'blob',
});
if (!isMounted || audioUrl !== currentAudioUrl) {
return;
}
const blob = response.data;
const newBlobUrl = URL.createObjectURL(blob);
setBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== newBlobUrl) {
URL.revokeObjectURL(prevBlobUrl);
}
return newBlobUrl;
});
setError(null);
} catch (err) {
console.error('Failed to load audio blob:', err);
if (isMounted && audioUrl === currentAudioUrl) {
setError('Failed to load audio. Please try again.');
setBlobUrl(null);
}
}
};
loadAudioBlob();
return () => {
isMounted = false;
// Cleanup blob URL when component unmounts or URL changes
setBlobUrl((prevBlobUrl) => {
if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return null;
});
};
}, [audioUrl]);
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio || !blobUrl) return;
const updateTime = () => setCurrentTime(audio.currentTime); const updateTime = () => setCurrentTime(audio.currentTime);
const updateDuration = () => setDuration(audio.duration); const updateDuration = () => setDuration(audio.duration);
const handleEnd = () => setPlaying(false); const handleEnd = () => setPlaying(false);
const handleError = () => {
setError('Audio playback error. Please try again.');
setPlaying(false);
};
audio.addEventListener("timeupdate", updateTime); audio.addEventListener("timeupdate", updateTime);
audio.addEventListener("loadedmetadata", updateDuration); audio.addEventListener("loadedmetadata", updateDuration);
audio.addEventListener("ended", handleEnd); audio.addEventListener("ended", handleEnd);
audio.addEventListener("error", handleError);
return () => { return () => {
audio.removeEventListener("timeupdate", updateTime); audio.removeEventListener("timeupdate", updateTime);
audio.removeEventListener("loadedmetadata", updateDuration); audio.removeEventListener("loadedmetadata", updateDuration);
audio.removeEventListener("ended", handleEnd); audio.removeEventListener("ended", handleEnd);
audio.removeEventListener("error", handleError);
}; };
}, [audioUrl]); }, [blobUrl]);
const togglePlay = () => { const togglePlay = async () => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio || !blobUrl) {
setError('Audio not loaded. Please wait...');
if (playing) { return;
audio.pause(); }
} else {
audio.play(); try {
if (playing) {
audio.pause();
setPlaying(false);
} else {
await audio.play();
setPlaying(true);
setError(null);
}
} catch (err) {
console.error('Playback error:', err);
setError('Failed to play audio. Please try again.');
setPlaying(false);
} }
setPlaying(!playing);
}; };
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
@@ -58,6 +162,8 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
setCurrentTime(newTime); setCurrentTime(newTime);
}; };
const effectiveAudioUrl = blobUrl || audioUrl;
return ( return (
<Paper <Paper
sx={{ sx={{
@@ -74,8 +180,26 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
{title} {title}
</Typography> </Typography>
)} )}
{error && (
<Alert severity="error" sx={{ py: 0.5 }}>
<Typography variant="caption">{error}</Typography>
</Alert>
)}
{!blobUrl && audioUrl && (
<Alert severity="info" sx={{ py: 0.5 }}>
<Typography variant="caption">Loading audio...</Typography>
</Alert>
)}
<Stack direction="row" spacing={2} alignItems="center"> <Stack direction="row" spacing={2} alignItems="center">
<IconButton onClick={togglePlay} sx={{ color: "#a78bfa" }} size="large"> <IconButton
onClick={togglePlay}
disabled={!effectiveAudioUrl || !!error}
sx={{ color: "#a78bfa" }}
size="large"
>
{playing ? <PauseCircleIcon fontSize="large" /> : <PlayCircleIcon fontSize="large" />} {playing ? <PauseCircleIcon fontSize="large" /> : <PlayCircleIcon fontSize="large" />}
</IconButton> </IconButton>
<Stack flex={1}> <Stack flex={1}>
@@ -85,7 +209,8 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
max={duration || 0} max={duration || 0}
value={currentTime} value={currentTime}
onChange={handleSeek} onChange={handleSeek}
style={{ width: "100%", cursor: "pointer" }} disabled={!effectiveAudioUrl}
style={{ width: "100%", cursor: effectiveAudioUrl ? "pointer" : "not-allowed" }}
/> />
<Stack direction="row" justifyContent="space-between" sx={{ mt: 0.5 }}> <Stack direction="row" justifyContent="space-between" sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@@ -99,18 +224,22 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
<Tooltip title="Download audio"> <Tooltip title="Download audio">
<IconButton <IconButton
onClick={() => { onClick={() => {
if (!effectiveAudioUrl) return;
const link = document.createElement("a"); const link = document.createElement("a");
link.href = audioUrl; link.href = effectiveAudioUrl;
link.download = title || "podcast-audio.mp3"; link.download = title || "podcast-audio.mp3";
link.click(); link.click();
}} }}
disabled={!effectiveAudioUrl}
sx={{ color: "rgba(255,255,255,0.7)" }} sx={{ color: "rgba(255,255,255,0.7)" }}
> >
<DownloadIcon /> <DownloadIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Stack> </Stack>
<audio ref={audioRef} src={audioUrl} preload="metadata" /> {effectiveAudioUrl && (
<audio ref={audioRef} src={effectiveAudioUrl} preload="metadata" />
)}
</Stack> </Stack>
</Paper> </Paper>
); );

View File

@@ -1,79 +1,26 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useState, useCallback } from "react";
import { import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha } from "@mui/material";
Box,
Paper,
Stack,
Typography,
Alert,
Chip,
Tooltip,
LinearProgress,
Stepper,
Step,
StepLabel,
Divider,
FormControl,
InputLabel,
Select,
MenuItem,
List,
ListItem,
ListItemButton,
ListItemText,
Checkbox,
CircularProgress,
alpha,
} from "@mui/material";
import {
Mic as MicIcon,
Psychology as PsychologyIcon,
Search as SearchIcon,
EditNote as EditNoteIcon,
PlayArrow as PlayArrowIcon,
CheckCircle as CheckCircleIcon,
Info as InfoIcon,
AutoAwesome as AutoAwesomeIcon,
Insights as InsightsIcon,
LibraryMusic as LibraryMusicIcon,
} from "@mui/icons-material";
import { ResearchProvider } from "../../services/blogWriterApi";
import { podcastApi } from "../../services/podcastApi";
import { usePodcastProjectState } from "../../hooks/usePodcastProjectState"; import { usePodcastProjectState } from "../../hooks/usePodcastProjectState";
import { useNavigate } from "react-router-dom"; import { Script } from "./types";
import { CreateProjectPayload, Job, Knobs, Query, Research, Script } from "./types";
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
import { CreateModal } from "./CreateModal"; import { CreateModal } from "./CreateModal";
import { AnalysisPanel } from "./AnalysisPanel"; import { AnalysisPanel } from "./AnalysisPanel";
import { FactCard } from "./FactCard";
import { ScriptEditor } from "./ScriptEditor"; import { ScriptEditor } from "./ScriptEditor";
import { RenderQueue } from "./RenderQueue"; import { RenderQueue } from "./RenderQueue";
import { RecentEpisodesPreview } from "./RecentEpisodesPreview"; import { RecentEpisodesPreview } from "./RecentEpisodesPreview";
import { ProjectList } from "./ProjectList"; import { ProjectList } from "./ProjectList";
import { usePreflightCheck } from "../../hooks/usePreflightCheck";
import { useBudgetTracking } from "../../hooks/useBudgetTracking";
import { PreflightBlockDialog } from "./PreflightBlockDialog"; import { PreflightBlockDialog } from "./PreflightBlockDialog";
import HeaderControls from "../shared/HeaderControls"; import {
Header,
/* ================= Helpers ================= */ ProgressStepper,
EstimateCard,
const DEFAULT_KNOBS: Knobs = { QuerySelection,
voice_emotion: "neutral", ResearchSummary,
voice_speed: 1, usePodcastWorkflow,
resolution: "720p", DEFAULT_KNOBS,
scene_length_target: 45, getStepLabel,
sample_rate: 24000, } from "./PodcastDashboard/index";
bitrate: "standard",
};
const announceError = (setAnnouncement: (msg: string) => void, error: unknown) => {
const message = error instanceof Error ? error.message : "Unexpected error";
setAnnouncement(message);
};
/* ================= Dashboard ================= */
const PodcastDashboard: React.FC = () => { const PodcastDashboard: React.FC = () => {
const navigate = useNavigate();
const projectState = usePodcastProjectState(); const projectState = usePodcastProjectState();
const [showProjectList, setShowProjectList] = useState(false); const [showProjectList, setShowProjectList] = useState(false);
const { const {
@@ -91,250 +38,39 @@ const PodcastDashboard: React.FC = () => {
showScriptEditor, showScriptEditor,
showRenderQueue, showRenderQueue,
currentStep, currentStep,
setProject,
setAnalysis,
setQueries,
setSelectedQueries,
setResearch,
setRawResearch,
setEstimate,
setScriptData, setScriptData,
updateRenderJob,
setKnobs,
setResearchProvider,
setBudgetCap,
setShowScriptEditor, setShowScriptEditor,
setShowRenderQueue, setShowRenderQueue,
initializeProject, setResearchProvider,
updateRenderJob,
resetState, resetState,
loadProjectFromDb, loadProjectFromDb,
setCurrentStep,
} = projectState; } = projectState;
const [isAnalyzing, setIsAnalyzing] = useState(false); const workflow = usePodcastWorkflow({
const [isResearching, setIsResearching] = useState(false); projectState,
const [announcement, setAnnouncement] = useState(""); onError: (msg: string) => {
const [showResumeAlert, setShowResumeAlert] = useState(false); // Error handling is done through workflow's own announcement system
const [showPreflightDialog, setShowPreflightDialog] = useState(false); console.error("Workflow error:", msg);
const [preflightResponse, setPreflightResponse] = useState<any>(null);
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
// Budget tracking
const budgetTracking = useBudgetTracking(projectState.budgetCap || 50);
// Preflight check hook
const preflightCheck = usePreflightCheck({
onBlocked: (response) => {
setPreflightResponse(response);
setShowPreflightDialog(true);
}, },
}); });
// Update budget cap when project state changes const handleSelectProject = useCallback(async (projectId: string) => {
useEffect(() => {
if (projectState.budgetCap) {
budgetTracking.setBudgetCap(projectState.budgetCap);
}
}, [projectState.budgetCap, budgetTracking]);
// Check if we have a saved project on mount
useEffect(() => {
if (project && currentStep && currentStep !== "create") {
setShowResumeAlert(true);
setTimeout(() => setShowResumeAlert(false), 5000);
}
}, []); // Only on mount
useEffect(() => {
if (announcement) {
const t = setTimeout(() => setAnnouncement(""), 4000);
return () => clearTimeout(t);
}
return undefined;
}, [announcement]);
const handleCreate = async (payload: CreateProjectPayload) => {
// Prevent duplicate submits that can spam story setup API
if (isAnalyzing) return;
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
setIsAnalyzing(true);
setAnnouncement("Analyzing your idea — AI suggestions incoming");
const result = await podcastApi.createProject(payload);
await initializeProject(payload, result.projectId);
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
setAnalysis(result.analysis);
setEstimate(result.estimate);
setQueries(result.queries);
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
setKnobs(payload.knobs);
setBudgetCap(payload.budgetCap);
setAnnouncement("Analysis complete");
} catch (error) {
announceError(setAnnouncement, error);
} finally {
setIsAnalyzing(false);
}
};
const handleRunResearch = async () => {
// Prevent duplicate research calls
if (isResearching) return;
if (!project) {
setAnnouncement("Create a project first.");
return;
}
if (selectedQueries.size === 0) {
setAnnouncement("Select at least one query to research.");
return;
}
// Preflight check before research
setPreflightOperationName("Research");
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
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 || "google",
});
if (!preflightResult.can_proceed) {
return; // Dialog will be shown by onBlocked callback
}
try {
setIsResearching(true);
setAnnouncement(`Starting ${researchProvider === "exa" ? "deep" : "standard"} research — this may take a moment...`);
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
const { research: mapped, raw } = await podcastApi.runResearch({
projectId: project.id,
topic: project.idea,
approvedQueries,
provider: researchProvider,
onProgress: (message) => {
// Update announcement with progress messages
setAnnouncement(message);
},
});
setResearch(mapped);
setRawResearch(raw);
setAnnouncement("Research complete — review fact cards below");
} catch (researchError) {
const errorMessage = researchError instanceof Error
? researchError.message
: "Research failed. Please try again or switch to Standard Research.";
// Provide helpful error messages
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
} else if (errorMessage.includes("timeout")) {
setAnnouncement("Research timed out. Please try again with fewer queries.");
} else {
setAnnouncement(`Research failed: ${errorMessage}`);
}
// Log full error for debugging
console.error("Research error:", researchError);
throw researchError;
}
} catch (error) {
announceError(setAnnouncement, error);
} finally {
setIsResearching(false);
}
};
const handleGenerateScript = async () => {
// Avoid re-triggering script generation preflight
if (showScriptEditor) return;
if (!project || !research) {
setAnnouncement("Project or research missing — cannot generate script");
return;
}
// Preflight check before script generation
setPreflightOperationName("Script Generation");
const preflightResult = await preflightCheck.check({
provider: "gemini",
operation_type: "script_generation",
tokens_requested: 2000,
actual_provider_name: "gemini",
});
if (!preflightResult.can_proceed) {
return; // Dialog will be shown by onBlocked callback
}
setScriptData(null);
setShowRenderQueue(false);
setShowScriptEditor(true);
};
const handleProceedToRendering = (script: Script) => {
setScriptData(script);
// Initialize render jobs if empty
if (renderJobs.length === 0) {
script.scenes.forEach((scene) => {
updateRenderJob(scene.id, {
sceneId: scene.id,
title: scene.title,
status: "idle" as const,
progress: 0,
previewUrl: null,
finalUrl: null,
jobId: null,
});
});
}
setShowRenderQueue(true);
setShowScriptEditor(false);
};
const selectedCount = selectedQueries.size;
const canGenerateScript = Boolean(project && research && rawResearch);
const toggleQuery = (id: string) => {
if (isResearching) return;
const current = selectedQueries;
const next = new Set<string>(current);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedQueries(next);
};
const activeStep = useMemo(() => {
if (showRenderQueue) return 3;
if (showScriptEditor) return 2;
if (research) return 1;
if (analysis) return 0;
return -1;
}, [showRenderQueue, showScriptEditor, research, analysis]);
const steps = [
{ label: "Analysis", icon: <PsychologyIcon />, description: "AI analyzes your idea" },
{ label: "Research", icon: <SearchIcon />, description: "Gather facts and citations" },
{ label: "Script", icon: <EditNoteIcon />, description: "Edit and approve scenes" },
{ label: "Render", icon: <PlayArrowIcon />, description: "Generate audio files" },
];
const handleSelectProject = async (projectId: string) => {
try { try {
await loadProjectFromDb(projectId); await loadProjectFromDb(projectId);
setShowProjectList(false); setShowProjectList(false);
} catch (error) { } catch (error) {
setAnnouncement(`Failed to load project: ${error instanceof Error ? error.message : "Unknown error"}`); const errorMsg = `Failed to load project: ${error instanceof Error ? error.message : "Unknown error"}`;
// Use workflow's setAnnouncement - workflow is stable from hook
workflow.setAnnouncement(errorMsg);
} }
}; }, [loadProjectFromDb, workflow.setAnnouncement]);
const handleNewEpisode = useCallback(() => {
resetState();
setShowProjectList(false);
}, [resetState]);
if (showProjectList) { if (showProjectList) {
return <ProjectList onSelectProject={handleSelectProject} />; return <ProjectList onSelectProject={handleSelectProject} />;
@@ -362,147 +98,93 @@ const PodcastDashboard: React.FC = () => {
> >
<Stack spacing={3}> <Stack spacing={3}>
{/* Header */} {/* Header */}
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}> <Header onShowProjects={() => setShowProjectList(true)} onNewEpisode={handleNewEpisode} />
<Box>
<Typography
variant="h3"
sx={{
color: "#1e293b",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1.5,
}}
>
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
AI Podcast Maker
</Typography>
<Typography variant="body2" color="text.secondary">
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
<SecondaryButton onClick={() => window.open("/docs", "_blank")} startIcon={<InfoIcon />}>
Help
</SecondaryButton>
<SecondaryButton
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
startIcon={<LibraryMusicIcon />}
tooltip="View all podcast episodes in Asset Library"
>
My Episodes
</SecondaryButton>
<SecondaryButton
onClick={() => setShowProjectList(true)}
startIcon={<MicIcon />}
tooltip="View and resume saved projects"
>
My Projects
</SecondaryButton>
<PrimaryButton
onClick={() => {
resetState();
setShowProjectList(false);
}}
startIcon={<AutoAwesomeIcon />}
>
New Episode
</PrimaryButton>
</Stack>
</Stack>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} /> <Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
{/* Progress Stepper */} {/* Progress Stepper */}
{project && activeStep >= 0 && ( {project && workflow.activeStep >= 0 && (
<Paper <ProgressStepper
sx={{ activeStep={workflow.activeStep}
p: 2.5, completedSteps={[
background: "#f8fafc", ...(analysis ? [0] : []), // Analysis step
border: "1px solid rgba(0,0,0,0.08)", ...(research ? [1] : []), // Research step
borderRadius: 2, ...(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');
}
}} }}
> />
<Stepper activeStep={activeStep} orientation="horizontal" sx={{ "& .MuiStepLabel-root": { cursor: "pointer" } }}>
{steps.map((step, index) => (
<Step key={step.label} completed={index < activeStep}>
<StepLabel
StepIconComponent={({ active, completed }) => (
<Box
sx={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: completed
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: active
? alpha("#667eea", 0.15)
: "#e2e8f0",
border: active ? "2px solid #667eea" : "1px solid rgba(0,0,0,0.1)",
color: completed || active ? "#fff" : "#64748b",
}}
>
{completed ? <CheckCircleIcon /> : step.icon}
</Box>
)}
>
<Typography variant="subtitle2">{step.label}</Typography>
<Typography variant="caption" color="text.secondary">
{step.description}
</Typography>
</StepLabel>
</Step>
))}
</Stepper>
</Paper>
)} )}
{/* Resume Alert */} {/* Resume Alert */}
{showResumeAlert && project && ( {workflow.showResumeAlert && project && (
<Alert <Alert
severity="success" severity="success"
onClose={() => setShowResumeAlert(false)} onClose={() => workflow.setShowResumeAlert(false)}
sx={{ sx={{
background: "#d1fae5", background: "#d1fae5",
border: "1px solid #a7f3d0", border: "1px solid #a7f3d0",
"& .MuiAlert-icon": { color: "#10b981" }, "& .MuiAlert-icon": { color: "#10b981" },
}} }}
> >
<Typography variant="body2"> <Box component="span" sx={{ fontSize: "0.875rem" }}>
<strong>Project Restored:</strong> Resuming from{" "} <strong>Project Restored:</strong> Resuming from {getStepLabel(currentStep)} step. Your progress has been saved.
{currentStep === "analysis" </Box>
? "Analysis"
: currentStep === "research"
? "Research"
: currentStep === "script"
? "Script Editing"
: "Rendering"}{" "}
step. Your progress has been saved.
</Typography>
</Alert> </Alert>
)} )}
{/* Announcements */} {/* Announcements */}
{announcement && ( {workflow.announcement && (
<Alert <Alert
severity="info" severity="info"
onClose={() => setAnnouncement("")} onClose={() => workflow.setAnnouncement("")}
sx={{ sx={{
background: "#dbeafe", background: "#dbeafe",
border: "1px solid #bfdbfe", border: "1px solid #bfdbfe",
"& .MuiAlert-icon": { color: "#3b82f6" }, "& .MuiAlert-icon": { color: "#3b82f6" },
}} }}
> >
{announcement} {workflow.announcement}
</Alert> </Alert>
)} )}
{(isAnalyzing || isResearching) && ( {(workflow.isAnalyzing || workflow.isResearching) && (
<Alert <Alert
severity="warning" severity="warning"
icon={<CircularProgress size={20} />} icon={<CircularProgress size={20} />}
@@ -511,17 +193,21 @@ const PodcastDashboard: React.FC = () => {
border: "1px solid #fde68a", border: "1px solid #fde68a",
}} }}
> >
<Typography variant="body2"> <Box component="span" sx={{ fontSize: "0.875rem" }}>
{isAnalyzing ? "Analyzing your idea with AI..." : "Running research... This may take a moment."} {workflow.isAnalyzing ? "Analyzing your idea with AI..." : "Running research... This may take a moment."}
</Typography> </Box>
</Alert> </Alert>
)} )}
{/* Create Modal */} {/* Create Modal */}
{!project && ( {!project && (
<> <>
<CreateModal open onCreate={handleCreate} defaultKnobs={DEFAULT_KNOBS} isSubmitting={isAnalyzing} /> <CreateModal
{/* Recent Episodes Preview */} open
onCreate={workflow.handleCreate}
defaultKnobs={DEFAULT_KNOBS}
isSubmitting={workflow.isAnalyzing}
/>
<RecentEpisodesPreview onSelectEpisode={() => {}} /> <RecentEpisodesPreview onSelectEpisode={() => {}} />
</> </>
)} )}
@@ -531,217 +217,32 @@ const PodcastDashboard: React.FC = () => {
{analysis && !showScriptEditor && !showRenderQueue && ( {analysis && !showScriptEditor && !showRenderQueue && (
<AnalysisPanel <AnalysisPanel
analysis={analysis} analysis={analysis}
onRegenerate={() => setAnalysis({ ...analysis })} onRegenerate={() => {}}
/> />
)} )}
{estimate && !showScriptEditor && !showRenderQueue && ( {estimate && !showScriptEditor && !showRenderQueue && (
<GlassyCard <EstimateCard estimate={estimate} />
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a",
}}
aria-label="estimate"
>
<Stack spacing={2}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<InsightsIcon />
Estimated Cost
</Typography>
<Typography variant="h4" sx={{ color: "#4f46e5", fontWeight: 800 }}>
${estimate.total.toFixed(2)}
</Typography>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<Chip
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
size="small"
title="Voice narration cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
<Chip
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
size="small"
title="Avatar/video cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
<Chip
label={`Research: $${estimate.researchCost.toFixed(2)}`}
size="small"
title="Research and fact-checking cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
</Stack>
</Stack>
</GlassyCard>
)} )}
{queries.length > 0 && !showScriptEditor && !showRenderQueue && ( {queries.length > 0 && !showScriptEditor && !showRenderQueue && (
<GlassyCard <QuerySelection
sx={{ queries={queries}
...glassyCardSx, selectedQueries={selectedQueries}
background: "#ffffff", researchProvider={researchProvider}
border: "1px solid rgba(0,0,0,0.06)", isResearching={workflow.isResearching}
boxShadow: "0 10px 28px rgba(15,23,42,0.06)", onToggleQuery={workflow.toggleQuery}
color: "#0f172a", onProviderChange={setResearchProvider}
}} onRunResearch={workflow.handleRunResearch}
> />
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<SearchIcon />
Research Queries
</Typography>
<Stack direction="row" spacing={2} alignItems="center">
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Provider</InputLabel>
<Select
value={researchProvider}
onChange={(e) => setResearchProvider(e.target.value as ResearchProvider)}
label="Provider"
disabled={isResearching}
size="small"
sx={{
backgroundColor: "#f8fafc",
"&:hover": {
backgroundColor: "#f1f5f9",
},
}}
>
<MenuItem value="google">
<Stack direction="row" spacing={1} alignItems="center">
<SearchIcon fontSize="small" />
<span>Standard Research</span>
</Stack>
</MenuItem>
<MenuItem value="exa">
<Stack direction="row" spacing={1} alignItems="center">
<AutoAwesomeIcon fontSize="small" />
<span>Deep Research</span>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Chip
label={`${selectedCount} / ${queries.length} selected`}
size="small"
color={selectedCount > 0 ? "primary" : "default"}
/>
</Stack>
</Stack>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Tooltip
title={
researchProvider === "google"
? "Standard Research: Fast, fact-checked results with source citations"
: "Deep Research: Comprehensive analysis with competitor insights and trending topics"
}
arrow
>
<Alert
severity="info"
sx={{
background: "#e0f2fe",
border: "1px solid #bae6fd",
color: "#0f172a",
}}
>
<Typography variant="caption" sx={{ color: "#0f172a" }}>
{researchProvider === "google"
? "Select at least one query (recommended: 3+ for balanced coverage). Standard research provides fact-checked results with source citations."
: "Select queries for deep research. This mode provides comprehensive analysis with competitor insights and trending topics."}
</Typography>
</Alert>
</Tooltip>
<List>
{queries.map((q) => (
<ListItem key={q.id} disablePadding>
<ListItemButton
onClick={() => toggleQuery(q.id)}
disabled={isResearching}
sx={{
borderRadius: 2,
mb: 1,
border: "1px solid rgba(0,0,0,0.08)",
background: "#f8fafc",
"&:hover": { background: alpha("#667eea", 0.08) },
}}
>
<Checkbox checked={selectedQueries.has(q.id)} edge="start" />
<ListItemText
primary={q.query}
secondary={q.rationale}
primaryTypographyProps={{ variant: "body2", fontWeight: 600, color: "#0f172a" }}
secondaryTypographyProps={{ variant: "caption", sx: { color: "#475569" } }}
/>
</ListItemButton>
</ListItem>
))}
</List>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<PrimaryButton
onClick={handleRunResearch}
disabled={!project || selectedCount === 0 || isResearching}
loading={isResearching}
startIcon={<SearchIcon />}
tooltip={
selectedCount === 0
? "Select at least one query to run research"
: `Run research with ${selectedCount} selected ${selectedCount === 1 ? "query" : "queries"}`
}
>
{isResearching ? "Running Research..." : "Run Research"}
</PrimaryButton>
</Box>
</Stack>
</GlassyCard>
)} )}
{research && !showScriptEditor && !showRenderQueue && ( {research && !showScriptEditor && !showRenderQueue && (
<GlassyCard sx={glassyCardSx}> <ResearchSummary
<Stack spacing={3}> research={research}
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}> canGenerateScript={workflow.canGenerateScript}
<Box> onGenerateScript={workflow.handleGenerateScript}
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}> />
<InsightsIcon />
Research Summary
</Typography>
<Typography variant="body2" color="text.secondary">
{research.summary}
</Typography>
</Box>
<PrimaryButton
onClick={handleGenerateScript}
disabled={!canGenerateScript}
startIcon={<EditNoteIcon />}
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
>
Generate Script
</PrimaryButton>
</Stack>
{research.factCards.length > 0 && (
<>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Fact Cards ({research.factCards.length})
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", lg: "1fr 1fr 1fr" }, gap: 2 }}>
{research.factCards.map((fact) => (
<FactCard key={fact.id} fact={fact} />
))}
</Box>
</>
)}
</Stack>
</GlassyCard>
)} )}
{showScriptEditor && project && research && rawResearch && ( {showScriptEditor && project && research && rawResearch && (
@@ -756,8 +257,8 @@ const PodcastDashboard: React.FC = () => {
script={scriptData} script={scriptData}
onScriptChange={(s) => setScriptData(s)} onScriptChange={(s) => setScriptData(s)}
onBackToResearch={() => setShowScriptEditor(false)} onBackToResearch={() => setShowScriptEditor(false)}
onProceedToRendering={(s) => handleProceedToRendering(s)} onProceedToRendering={(s) => workflow.handleProceedToRendering(s)}
onError={(msg) => setAnnouncement(msg)} onError={(msg) => workflow.setAnnouncement(msg)}
/> />
)} )}
@@ -776,11 +277,12 @@ const PodcastDashboard: React.FC = () => {
budgetCap={projectState.budgetCap} budgetCap={projectState.budgetCap}
avatarImageUrl={null} avatarImageUrl={null}
onUpdateJob={updateRenderJob} onUpdateJob={updateRenderJob}
onUpdateScript={(updatedScript) => setScriptData(updatedScript)}
onBack={() => { onBack={() => {
setShowRenderQueue(false); setShowRenderQueue(false);
setShowScriptEditor(true); setShowScriptEditor(true);
}} }}
onError={(msg) => setAnnouncement(msg)} onError={(msg) => workflow.setAnnouncement(msg)}
/> />
)} )}
</Stack> </Stack>
@@ -789,13 +291,13 @@ const PodcastDashboard: React.FC = () => {
{/* Preflight Block Dialog */} {/* Preflight Block Dialog */}
<PreflightBlockDialog <PreflightBlockDialog
open={showPreflightDialog} open={workflow.showPreflightDialog}
onClose={() => { onClose={() => {
setShowPreflightDialog(false); workflow.setShowPreflightDialog(false);
setPreflightResponse(null); workflow.setPreflightResponse(null);
}} }}
response={preflightResponse} response={workflow.preflightResponse}
operationName={preflightOperationName} operationName={workflow.preflightOperationName}
/> />
</Box> </Box>
); );

View File

@@ -0,0 +1,56 @@
import React from "react";
import { Stack, Typography, Chip, Divider } from "@mui/material";
import { Insights as InsightsIcon } from "@mui/icons-material";
import { PodcastEstimate } from "../types";
import { GlassyCard, glassyCardSx } from "../ui";
interface EstimateCardProps {
estimate: PodcastEstimate;
}
export const EstimateCard: React.FC<EstimateCardProps> = ({ estimate }) => {
return (
<GlassyCard
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a",
}}
aria-label="estimate"
>
<Stack spacing={2}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<InsightsIcon />
Estimated Cost
</Typography>
<Typography variant="h4" sx={{ color: "#4f46e5", fontWeight: 800 }}>
${estimate.total.toFixed(2)}
</Typography>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<Chip
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
size="small"
title="Voice narration cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
<Chip
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
size="small"
title="Avatar/video cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
<Chip
label={`Research: $${estimate.researchCost.toFixed(2)}`}
size="small"
title="Research and fact-checking cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
</Stack>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,68 @@
import React from "react";
import { Box, Stack, Typography } from "@mui/material";
import {
Mic as MicIcon,
Info as InfoIcon,
AutoAwesome as AutoAwesomeIcon,
LibraryMusic as LibraryMusicIcon,
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import { PrimaryButton, SecondaryButton } from "../ui";
import HeaderControls from "../../shared/HeaderControls";
interface HeaderProps {
onShowProjects: () => void;
onNewEpisode: () => void;
}
export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode }) => {
const navigate = useNavigate();
return (
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box>
<Typography
variant="h3"
sx={{
color: "#1e293b",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1.5,
}}
>
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
AI Podcast Maker
</Typography>
<Typography variant="body2" color="text.secondary">
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
<SecondaryButton onClick={() => window.open("/docs", "_blank")} startIcon={<InfoIcon />}>
Help
</SecondaryButton>
<SecondaryButton
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
startIcon={<LibraryMusicIcon />}
tooltip="View all podcast episodes in Asset Library"
>
My Episodes
</SecondaryButton>
<SecondaryButton
onClick={onShowProjects}
startIcon={<MicIcon />}
tooltip="View and resume saved projects"
>
My Projects
</SecondaryButton>
<PrimaryButton onClick={onNewEpisode} startIcon={<AutoAwesomeIcon />}>
New Episode
</PrimaryButton>
</Stack>
</Stack>
);
};

View File

@@ -0,0 +1,105 @@
import React from "react";
import { Box, Paper, Stepper, Step, StepLabel, Typography, alpha } from "@mui/material";
import {
Psychology as PsychologyIcon,
Search as SearchIcon,
EditNote as EditNoteIcon,
PlayArrow as PlayArrowIcon,
CheckCircle as CheckCircleIcon,
} from "@mui/icons-material";
interface ProgressStepperProps {
activeStep: number;
completedSteps?: number[]; // Steps that have been completed (have data)
onStepClick?: (stepIndex: number) => void;
}
const steps = [
{ label: "Analysis", icon: <PsychologyIcon />, description: "AI analyzes your idea" },
{ label: "Research", icon: <SearchIcon />, description: "Gather facts and citations" },
{ label: "Script", icon: <EditNoteIcon />, description: "Edit and approve scenes" },
{ label: "Render", icon: <PlayArrowIcon />, description: "Generate audio files" },
];
export const ProgressStepper: React.FC<ProgressStepperProps> = ({ activeStep, completedSteps = [], onStepClick }) => {
if (activeStep < 0) return null;
const handleStepClick = (stepIndex: number) => {
// Allow navigation to any completed step (has data), not just steps before active step
const isCompleted = completedSteps.includes(stepIndex);
if (isCompleted && onStepClick) {
onStepClick(stepIndex);
}
};
return (
<Paper
sx={{
p: 2.5,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 2,
}}
>
<Stepper activeStep={activeStep} orientation="horizontal">
{steps.map((step, index) => {
const isCompleted = completedSteps.includes(index);
const isClickable = isCompleted && onStepClick !== undefined;
return (
<Step key={step.label} completed={isCompleted}>
<StepLabel
onClick={() => handleStepClick(index)}
sx={{
cursor: isClickable ? "pointer" : "default",
"&:hover": isClickable
? {
"& .MuiStepLabel-label": {
color: "#667eea",
},
}
: {},
}}
StepIconComponent={({ active, completed }) => (
<Box
sx={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: completed
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: active
? alpha("#667eea", 0.15)
: "#e2e8f0",
border: active ? "2px solid #667eea" : "1px solid rgba(0,0,0,0.1)",
color: completed || active ? "#fff" : "#64748b",
transition: "all 0.2s ease",
...(isClickable && {
cursor: "pointer",
"&:hover": {
transform: "scale(1.05)",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
},
}),
}}
>
{completed ? <CheckCircleIcon /> : step.icon}
</Box>
)}
>
<Typography variant="subtitle2">{step.label}</Typography>
<Typography variant="caption" color="text.secondary">
{step.description}
</Typography>
</StepLabel>
</Step>
);
})}
</Stepper>
</Paper>
);
};

View File

@@ -0,0 +1,169 @@
import React from "react";
import {
Stack,
Typography,
Chip,
Tooltip,
Alert,
List,
ListItem,
ListItemButton,
ListItemText,
Checkbox,
FormControl,
InputLabel,
Select,
MenuItem,
Box,
alpha,
} from "@mui/material";
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
import { ResearchProvider } from "../../../services/blogWriterApi";
import { Query } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
interface QuerySelectionProps {
queries: Query[];
selectedQueries: Set<string>;
researchProvider: ResearchProvider;
isResearching: boolean;
onToggleQuery: (id: string) => void;
onProviderChange: (provider: ResearchProvider) => void;
onRunResearch: () => void;
}
export const QuerySelection: React.FC<QuerySelectionProps> = ({
queries,
selectedQueries,
researchProvider,
isResearching,
onToggleQuery,
onProviderChange,
onRunResearch,
}) => {
const selectedCount = selectedQueries.size;
return (
<GlassyCard
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a",
}}
>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<SearchIcon />
Research Queries
</Typography>
<Stack direction="row" spacing={2} alignItems="center">
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Provider</InputLabel>
<Select
value={researchProvider}
onChange={(e) => onProviderChange(e.target.value as ResearchProvider)}
label="Provider"
disabled={isResearching}
size="small"
sx={{
backgroundColor: "#f8fafc",
"&:hover": {
backgroundColor: "#f1f5f9",
},
}}
>
<MenuItem value="google">
<Stack direction="row" spacing={1} alignItems="center">
<SearchIcon fontSize="small" />
<span>Standard Research</span>
</Stack>
</MenuItem>
<MenuItem value="exa">
<Stack direction="row" spacing={1} alignItems="center">
<AutoAwesomeIcon fontSize="small" />
<span>Deep Research</span>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Chip
label={`${selectedCount} / ${queries.length} selected`}
size="small"
color={selectedCount > 0 ? "primary" : "default"}
/>
</Stack>
</Stack>
<Tooltip
title={
researchProvider === "google"
? "Standard Research: Fast, fact-checked results with source citations"
: "Deep Research: Comprehensive analysis with competitor insights and trending topics"
}
arrow
>
<Alert
severity="info"
sx={{
background: "#e0f2fe",
border: "1px solid #bae6fd",
color: "#0f172a",
}}
>
<Typography variant="caption" sx={{ color: "#0f172a" }}>
{researchProvider === "google"
? "Select at least one query (recommended: 3+ for balanced coverage). Standard research provides fact-checked results with source citations."
: "Select queries for deep research. This mode provides comprehensive analysis with competitor insights and trending topics."}
</Typography>
</Alert>
</Tooltip>
<List>
{queries.map((q) => (
<ListItem key={q.id} disablePadding>
<ListItemButton
onClick={() => onToggleQuery(q.id)}
disabled={isResearching}
sx={{
borderRadius: 2,
mb: 1,
border: "1px solid rgba(0,0,0,0.08)",
background: "#f8fafc",
"&:hover": { background: alpha("#667eea", 0.08) },
}}
>
<Checkbox checked={selectedQueries.has(q.id)} edge="start" />
<ListItemText
primary={q.query}
secondary={q.rationale}
primaryTypographyProps={{ variant: "body2", fontWeight: 600, color: "#0f172a" }}
secondaryTypographyProps={{ variant: "caption", sx: { color: "#475569" } }}
/>
</ListItemButton>
</ListItem>
))}
</List>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<PrimaryButton
onClick={onRunResearch}
disabled={selectedCount === 0 || isResearching}
loading={isResearching}
startIcon={<SearchIcon />}
tooltip={
selectedCount === 0
? "Select at least one query to run research"
: `Run research with ${selectedCount} selected ${selectedCount === 1 ? "query" : "queries"}`
}
>
{isResearching ? "Running Research..." : "Run Research"}
</PrimaryButton>
</Box>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,148 @@
import React from "react";
import { Stack, Typography, Chip, Divider, Box, alpha } from "@mui/material";
import {
Insights as InsightsIcon,
Search as SearchIcon,
AttachMoney as AttachMoneyIcon,
EditNote as EditNoteIcon,
} from "@mui/icons-material";
import { Research } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { FactCard } from "../FactCard";
interface ResearchSummaryProps {
research: Research;
canGenerateScript: boolean;
onGenerateScript: () => void;
}
export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
research,
canGenerateScript,
onGenerateScript,
}) => {
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
<InsightsIcon />
Research Summary
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, lineHeight: 1.7 }}>
{research.summary}
</Typography>
{/* Research Metadata */}
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap sx={{ mb: 2 }}>
{research.searchQueries && research.searchQueries.length > 0 && (
<Chip
icon={<SearchIcon />}
label={`${research.searchQueries.length} search${research.searchQueries.length > 1 ? "es" : ""}`}
size="small"
sx={{
background: alpha("#667eea", 0.1),
color: "#667eea",
fontWeight: 600,
border: "1px solid rgba(102, 126, 234, 0.2)",
}}
/>
)}
{research.searchType && (
<Chip
label={`${research.searchType.charAt(0).toUpperCase() + research.searchType.slice(1)} search`}
size="small"
sx={{
background: alpha("#10b981", 0.1),
color: "#059669",
fontWeight: 600,
border: "1px solid rgba(16, 185, 129, 0.2)",
}}
/>
)}
{research.sourceCount !== undefined && (
<Chip
label={`${research.sourceCount} source${research.sourceCount !== 1 ? "s" : ""}`}
size="small"
sx={{
background: alpha("#6366f1", 0.1),
color: "#4f46e5",
fontWeight: 600,
border: "1px solid rgba(99, 102, 241, 0.2)",
}}
/>
)}
{research.cost !== undefined && (
<Chip
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
label={`$${research.cost.toFixed(3)}`}
size="small"
sx={{
background: alpha("#f59e0b", 0.1),
color: "#d97706",
fontWeight: 600,
border: "1px solid rgba(245, 158, 11, 0.2)",
}}
/>
)}
</Stack>
{/* Search Queries Used */}
{research.searchQueries && research.searchQueries.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 600 }}>
Search Queries Used
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{research.searchQueries.map((query, idx) => (
<Chip
key={idx}
label={query}
size="small"
variant="outlined"
sx={{
borderColor: "rgba(102, 126, 234, 0.3)",
color: "#475569",
background: alpha("#f8fafc", 0.8),
fontSize: "0.8125rem",
}}
/>
))}
</Stack>
</Box>
)}
</Box>
<PrimaryButton
onClick={onGenerateScript}
disabled={!canGenerateScript}
startIcon={<EditNoteIcon />}
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
>
Generate Script
</PrimaryButton>
</Stack>
{research.factCards.length > 0 && (
<>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600 }}>
Research Sources & Facts ({research.factCards.length})
</Typography>
<Typography variant="caption" sx={{ color: "#64748b" }}>
Click any card to view source details
</Typography>
</Stack>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", lg: "1fr 1fr 1fr" }, gap: 2 }}>
{research.factCards.map((fact) => (
<FactCard key={fact.id} fact={fact} />
))}
</Box>
</>
)}
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,8 @@
export { Header } from "./Header";
export { ProgressStepper } from "./ProgressStepper";
export { EstimateCard } from "./EstimateCard";
export { QuerySelection } from "./QuerySelection";
export { ResearchSummary } from "./ResearchSummary";
export { usePodcastWorkflow } from "./usePodcastWorkflow";
export * from "./utils";

View File

@@ -0,0 +1,302 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { ResearchProvider, ResearchConfig } from "../../../services/blogWriterApi";
import { podcastApi } from "../../../services/podcastApi";
import { usePreflightCheck } from "../../../hooks/usePreflightCheck";
import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
import { CreateProjectPayload, Query, Research, Script, Job } from "../types";
import { usePodcastProjectState } from "../../../hooks/usePodcastProjectState";
import { sanitizeExaConfig, announceError, getStepLabel } from "./utils";
type PodcastProjectStateReturn = ReturnType<typeof usePodcastProjectState>;
interface UsePodcastWorkflowProps {
projectState: PodcastProjectStateReturn;
onError: (message: string) => void;
}
export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflowProps) => {
const {
project,
analysis,
queries,
selectedQueries,
research,
rawResearch,
researchProvider,
showScriptEditor,
showRenderQueue,
currentStep,
renderJobs,
budgetCap,
setProject,
setAnalysis,
setQueries,
setSelectedQueries,
setResearch,
setRawResearch,
setEstimate,
setScriptData,
setShowScriptEditor,
setShowRenderQueue,
setKnobs,
setResearchProvider,
setBudgetCap,
updateRenderJob,
initializeProject,
} = projectState;
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isResearching, setIsResearching] = useState(false);
const [announcement, setAnnouncement] = useState("");
const [showResumeAlert, setShowResumeAlert] = useState(false);
const [showPreflightDialog, setShowPreflightDialog] = useState(false);
const [preflightResponse, setPreflightResponse] = useState<any>(null);
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
const budgetTracking = useBudgetTracking(budgetCap || 50);
const preflightCheck = usePreflightCheck({
onBlocked: (response) => {
setPreflightResponse(response);
setShowPreflightDialog(true);
},
});
// Update budget cap when project state changes
useEffect(() => {
if (budgetCap) {
budgetTracking.setBudgetCap(budgetCap);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [budgetCap]);
// Check if we have a saved project on mount
useEffect(() => {
if (project && currentStep && currentStep !== "create") {
setShowResumeAlert(true);
setTimeout(() => setShowResumeAlert(false), 5000);
}
}, []); // Only on mount
useEffect(() => {
if (announcement) {
const t = setTimeout(() => setAnnouncement(""), 4000);
return () => clearTimeout(t);
}
return undefined;
}, [announcement]);
const handleCreate = useCallback(async (payload: CreateProjectPayload) => {
if (isAnalyzing) return;
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
setIsAnalyzing(true);
setAnnouncement("Analyzing your idea — AI suggestions incoming");
const result = await podcastApi.createProject(payload);
await initializeProject(payload, result.projectId);
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
setAnalysis(result.analysis);
setEstimate(result.estimate);
setQueries(result.queries);
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
setKnobs(payload.knobs);
setBudgetCap(payload.budgetCap);
setAnnouncement("Analysis complete");
} catch (error: any) {
if (error?.response?.status === 429 || error?.response?.data?.detail) {
const errorDetail = error.response.data.detail;
if (typeof errorDetail === 'object' && errorDetail.error && errorDetail.error.includes('limit')) {
const usageInfo = errorDetail.usage_info || {};
const blockedResponse = {
can_proceed: false,
estimated_cost: 0,
operations: [{
provider: errorDetail.provider || 'huggingface',
operation_type: 'ai_text_generation',
cost: 0,
allowed: false,
limit_info: usageInfo.limit_info || null,
message: errorDetail.message || errorDetail.error || 'Subscription limit exceeded',
}],
total_cost: 0,
usage_summary: usageInfo.usage_summary || null,
cached: false,
};
setPreflightResponse(blockedResponse);
setPreflightOperationName('Podcast Analysis');
setShowPreflightDialog(true);
setAnnouncement("Subscription limit reached. Please upgrade to continue.");
} else {
const message = typeof errorDetail === 'string' ? errorDetail : errorDetail.message || errorDetail.error || 'Request limit exceeded';
announceError(setAnnouncement, new Error(message));
}
} else {
announceError(setAnnouncement, error);
}
} finally {
setIsAnalyzing(false);
}
}, [isAnalyzing, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, initializeProject, setProject, setAnalysis, setEstimate, setQueries, setSelectedQueries, setKnobs, setBudgetCap]);
const handleRunResearch = useCallback(async () => {
if (isResearching) return;
if (!project) {
setAnnouncement("Create a project first.");
return;
}
if (selectedQueries.size === 0) {
setAnnouncement("Select at least one query to research.");
return;
}
setPreflightOperationName("Research");
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
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);
setAnnouncement(`Starting ${researchProvider === "exa" ? "deep" : "standard"} research — this may take a moment...`);
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
const { research: mapped, raw } = await podcastApi.runResearch({
projectId: project.id,
topic: project.idea,
approvedQueries,
provider: researchProvider,
exaConfig: sanitizeExaConfig(analysis?.exaSuggestedConfig),
onProgress: (message) => {
setAnnouncement(message);
},
});
setResearch(mapped);
setRawResearch(raw);
setAnnouncement("Research complete — review fact cards below");
} catch (researchError) {
const errorMessage = researchError instanceof Error
? researchError.message
: "Research failed. Please try again or switch to Standard Research.";
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
} else if (errorMessage.includes("timeout")) {
setAnnouncement("Research timed out. Please try again with fewer queries.");
} else {
setAnnouncement(`Research failed: ${errorMessage}`);
}
console.error("Research error:", researchError);
throw researchError;
}
} catch (error) {
announceError(setAnnouncement, error);
} finally {
setIsResearching(false);
}
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue]);
const handleGenerateScript = useCallback(async () => {
if (showScriptEditor) return;
if (!project || !research) {
setAnnouncement("Project or research missing — cannot generate script");
return;
}
setPreflightOperationName("Script Generation");
const preflightResult = await preflightCheck.check({
provider: "gemini",
operation_type: "script_generation",
tokens_requested: 2000,
actual_provider_name: "gemini",
});
if (!preflightResult.can_proceed) {
return;
}
setScriptData(null);
setShowRenderQueue(false);
setShowScriptEditor(true);
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor]);
const handleProceedToRendering = useCallback((script: Script) => {
setScriptData(script);
if (renderJobs.length === 0) {
script.scenes.forEach((scene) => {
const hasExistingAudio = Boolean(scene.audioUrl);
updateRenderJob(scene.id, {
sceneId: scene.id,
title: scene.title,
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
progress: hasExistingAudio ? 100 : 0,
previewUrl: null,
finalUrl: hasExistingAudio ? scene.audioUrl : null,
jobId: null,
});
});
}
setShowRenderQueue(true);
setShowScriptEditor(false);
}, [renderJobs.length, setScriptData, updateRenderJob, setShowRenderQueue, setShowScriptEditor]);
const toggleQuery = useCallback((id: string) => {
if (isResearching) return;
const current = selectedQueries;
const next = new Set<string>(current);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedQueries(next);
}, [isResearching, selectedQueries, setSelectedQueries]);
const activeStep = useMemo(() => {
if (showRenderQueue) return 3;
if (showScriptEditor) return 2;
if (research) return 1;
if (analysis) return 0;
return -1;
}, [showRenderQueue, showScriptEditor, research, analysis]);
const canGenerateScript = Boolean(project && research && rawResearch);
return {
// State
isAnalyzing,
isResearching,
announcement,
showResumeAlert,
showPreflightDialog,
preflightResponse,
preflightOperationName,
activeStep,
canGenerateScript,
// Handlers
handleCreate,
handleRunResearch,
handleGenerateScript,
handleProceedToRendering,
toggleQuery,
setAnnouncement,
setShowResumeAlert,
setShowPreflightDialog,
setPreflightResponse,
setResearchProvider,
getStepLabel,
};
};

View File

@@ -0,0 +1,76 @@
import { ResearchConfig, DateRange } from "../../../services/blogWriterApi";
import { CreateProjectPayload, Knobs } from "../types";
export const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
export const allowedDateRanges: DateRange[] = [
"last_week",
"last_month",
"last_3_months",
"last_6_months",
"last_year",
"all_time",
];
export const sanitizeExaConfig = (
exa?: CreateProjectPayload["knobs"] & any & { exa_suggested_config?: any } | any
): ResearchConfig | undefined => {
if (!exa) return undefined;
const cfg = exa as {
exa_search_type?: "auto" | "keyword" | "neural";
exa_category?: string;
exa_include_domains?: string[];
exa_exclude_domains?: string[];
max_sources?: number;
include_statistics?: boolean;
date_range?: string;
};
const searchType: ResearchConfig["exa_search_type"] =
cfg.exa_search_type && ["auto", "keyword", "neural"].includes(cfg.exa_search_type)
? cfg.exa_search_type
: undefined;
const dateRange: DateRange | undefined = cfg.date_range && allowedDateRanges.includes(cfg.date_range as DateRange)
? (cfg.date_range as DateRange)
: undefined;
return {
provider: "exa",
exa_search_type: searchType,
exa_category: cfg.exa_category,
exa_include_domains: cfg.exa_include_domains,
exa_exclude_domains: cfg.exa_exclude_domains,
max_sources: cfg.max_sources,
include_statistics: cfg.include_statistics,
date_range: dateRange,
};
};
export const announceError = (setAnnouncement: (msg: string) => void, error: unknown) => {
const message = error instanceof Error ? error.message : "Unexpected error";
setAnnouncement(message);
};
export const getStepLabel = (step: string | null): string => {
switch (step) {
case "analysis":
return "Analysis";
case "research":
return "Research";
case "script":
return "Script Editing";
case "render":
return "Rendering";
default:
return "Unknown";
}
};

View File

@@ -1,23 +1,15 @@
import React, { useEffect, useState, useRef } from "react"; import React, { useCallback } from "react";
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, Button, CircularProgress, alpha } from "@mui/material"; import { Box, Stack, Typography, Alert, Paper, alpha } from "@mui/material";
import { import {
PlayArrow as PlayArrowIcon, PlayArrow as PlayArrowIcon,
ArrowBack as ArrowBackIcon, ArrowBack as ArrowBackIcon,
VolumeUp as VolumeUpIcon,
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
Info as InfoIcon,
OpenInNew as OpenInNewIcon,
Download as DownloadIcon,
Share as ShareIcon,
Refresh as RefreshIcon,
Videocam as VideocamIcon,
Cancel as CancelIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { Script, Knobs, Job, RenderJobResult, TaskStatus } from "./types"; import { Script, Knobs, Job } from "./types";
import { podcastApi } from "../../services/podcastApi"; import { SecondaryButton } from "./ui";
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui"; import { SceneCard } from "./RenderQueue/SceneCard";
import { InlineAudioPlayer } from "./InlineAudioPlayer"; import { SummaryStats } from "./RenderQueue/SummaryStats";
import { GuidancePanel } from "./RenderQueue/GuidancePanel";
import { useRenderQueue } from "./RenderQueue/useRenderQueue";
interface RenderQueueProps { interface RenderQueueProps {
projectId: string; projectId: string;
@@ -27,307 +19,81 @@ interface RenderQueueProps {
budgetCap?: number; budgetCap?: number;
avatarImageUrl?: string | null; avatarImageUrl?: string | null;
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void; onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
onUpdateScript?: (script: Script) => void;
onBack: () => void; onBack: () => void;
onError: (message: string) => void; onError: (message: string) => void;
} }
const getSceneVoiceEmotion = (knobs: Knobs) => knobs.voice_emotion || "neutral"; export const RenderQueue: React.FC<RenderQueueProps> = ({
projectId,
script,
knobs,
jobs,
budgetCap,
avatarImageUrl,
onUpdateJob,
onUpdateScript,
onBack,
onError,
}) => {
const {
rendering,
generatingImage,
isBusy,
runRender,
runImageGeneration,
runVideoRender,
} = useRenderQueue({
script,
jobs,
knobs,
projectId,
budgetCap,
avatarImageUrl,
onUpdateJob,
onUpdateScript,
onError,
});
export const RenderQueue: React.FC<RenderQueueProps> = ({ projectId, script, knobs, jobs, budgetCap, avatarImageUrl, onUpdateJob, onBack, onError }) => { const handleDownloadAudio = useCallback((audioUrl: string, title: string) => {
const [rendering, setRendering] = useState<string | null>(null); const link = document.createElement("a");
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map()); link.href = audioUrl;
const isBusy = Boolean(rendering); link.download = `${title.replace(/\s+/g, "-")}.mp3`;
link.click();
// Cleanup polling intervals on unmount
useEffect(() => {
return () => {
pollingIntervals.current.forEach((interval) => clearInterval(interval));
pollingIntervals.current.clear();
};
}, []); }, []);
// Initialize jobs if empty const handleDownloadVideo = useCallback((videoUrl: string, title: string) => {
useEffect(() => { const link = document.createElement("a");
if (jobs.length === 0 && script.scenes.length > 0) { link.href = videoUrl;
const initialJobs: Job[] = script.scenes.map((s) => ({ link.download = `${title.replace(/\s+/g, "-")}.mp4`;
sceneId: s.id, link.click();
title: s.title, }, []);
status: "idle" as const,
progress: 0,
previewUrl: null,
finalUrl: null,
jobId: null,
}));
// Update all jobs at once
initialJobs.forEach((job) => {
onUpdateJob(job.sceneId, job);
});
}
}, [script.scenes.length, jobs.length, onUpdateJob]);
const getScene = (sceneId: string) => script.scenes.find((s) => s.id === sceneId); const handleShare = useCallback(async (audioUrl: string, title: string) => {
if (navigator.share && audioUrl) {
const pollTaskStatus = async (taskId: string, sceneId: string) => {
try {
const status: TaskStatus = await podcastApi.pollTaskStatus(taskId);
onUpdateJob(sceneId, {
progress: status.progress ?? 0,
status: status.status === "completed" ? "completed" : status.status === "failed" ? "failed" : "running",
});
if (status.status === "completed" && status.result) {
const result = status.result;
const updates: Partial<Job> = {
status: "completed",
progress: 100,
videoUrl: result.video_url,
cost: result.cost,
};
onUpdateJob(sceneId, updates);
// Clear polling interval
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
} else if (status.status === "failed") {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
// Clear polling interval
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
onError(status.error || "Video generation failed");
}
return status.status === "completed" || status.status === "failed";
} catch (error) {
console.error("Error polling task status:", error);
return false;
}
};
const startPolling = (taskId: string, sceneId: string) => {
// Clear any existing interval for this scene
const existingInterval = pollingIntervals.current.get(sceneId);
if (existingInterval) {
clearInterval(existingInterval);
}
// Poll every 3 seconds
const interval = setInterval(async () => {
const isComplete = await pollTaskStatus(taskId, sceneId);
if (isComplete) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
}, 3000);
pollingIntervals.current.set(sceneId, interval);
};
const cancelRender = async (sceneId: string) => {
const job = jobs.find((j) => j.sceneId === sceneId);
if (job?.taskId) {
try { try {
await podcastApi.cancelTask(job.taskId); await navigator.share({
onUpdateJob(sceneId, { status: "cancelled", progress: 0 }); title,
text: `Check out this podcast episode: ${title}`,
// Clear polling interval url: audioUrl,
const interval = pollingIntervals.current.get(sceneId); });
if (interval) { } catch (err) {
clearInterval(interval); // User cancelled or error
pollingIntervals.current.delete(sceneId);
}
} catch (error) {
console.error("Error cancelling task:", error);
onError("Failed to cancel render job");
} }
} else {
// Fallback: copy to clipboard
await navigator.clipboard.writeText(audioUrl);
alert("Audio URL copied to clipboard!");
} }
}; }, []);
const runRender = async (sceneId: string, mode: "preview" | "full") => { const allScenesCompleted =
// Prevent double-fire while another render is in-flight (jobs.length > 0 && jobs.every((j) => j.status === "completed" && j.imageUrl)) ||
if (rendering && rendering !== sceneId) return; (script.scenes.length > 0 && script.scenes.every((s) => s.audioUrl && s.imageUrl));
const job = jobs.find((j) => j.sceneId === sceneId);
if (job && job.status !== "idle") return;
const scene = getScene(sceneId);
if (!scene) return;
// Estimate cost (rough estimate: ~$0.05 per 1000 chars)
const textLength = scene.lines.map((l) => l.text).join(" ").length;
const estimatedCost = (textLength / 1000) * 0.05;
// Check budget cap if provided
if (budgetCap && budgetCap > 0) {
const currentSpent = jobs
.filter((j) => j.status === "completed" && j.cost)
.reduce((sum, j) => sum + (j.cost || 0), 0);
if (currentSpent + estimatedCost > budgetCap) {
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(4)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
return;
}
}
setRendering(sceneId);
onUpdateJob(sceneId, {
status: mode === "preview" ? "previewing" : "running",
progress: mode === "preview" ? 25 : 40,
});
try {
const result: RenderJobResult = await podcastApi.renderSceneAudio({
scene,
voiceId: "Wise_Woman",
emotion: getSceneVoiceEmotion(knobs),
speed: knobs.voice_speed,
});
const updates: Partial<Job> = {
status: "completed",
progress: 100,
cost: result.cost,
provider: result.provider,
voiceId: result.voiceId,
fileSize: result.fileSize,
};
if (mode === "preview") {
updates.previewUrl = result.audioUrl;
window.open(result.audioUrl, "_blank");
} else {
updates.finalUrl = result.audioUrl;
// Save to asset library when final render completes
try {
await podcastApi.saveAudioToAssetLibrary({
audioUrl: result.audioUrl,
filename: result.audioFilename,
title: `${script.scenes.find((s) => s.id === sceneId)?.title || "Scene"} - ${projectId}`,
description: `Podcast episode scene audio: ${scene.title}`,
projectId,
sceneId,
cost: result.cost,
provider: result.provider,
model: result.model,
fileSize: result.fileSize,
});
} catch (assetError) {
console.error("Failed to save to asset library:", assetError);
// Don't fail the render if asset save fails
}
}
onUpdateJob(sceneId, updates);
} catch (error) {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
const message = error instanceof Error ? error.message : "Render failed";
onError(message);
} finally {
setRendering(null);
}
};
const runVideoRender = async (sceneId: string) => {
// Prevent double-fire while another render is in-flight
if (rendering && rendering !== sceneId) return;
const scene = getScene(sceneId);
if (!scene) return;
if (!avatarImageUrl) {
onError("Avatar image is required for video generation. Please upload an avatar image in project settings.");
return;
}
const job = jobs.find((j) => j.sceneId === sceneId);
if (!job?.finalUrl) {
onError("Please generate audio first before creating video.");
return;
}
// Estimate cost (video generation is ~$0.30 per 5 seconds at 720p)
const estimatedCost = 0.30; // Base cost per video
// Check budget cap if provided
if (budgetCap && budgetCap > 0) {
const currentSpent = jobs
.filter((j) => j.status === "completed" && j.cost)
.reduce((sum, j) => sum + (j.cost || 0), 0);
if (currentSpent + estimatedCost > budgetCap) {
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(2)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
return;
}
}
setRendering(sceneId);
onUpdateJob(sceneId, {
status: "running",
progress: 5,
});
try {
const result = await podcastApi.generateVideo({
projectId,
sceneId,
sceneTitle: scene.title,
audioUrl: job.finalUrl,
avatarImageUrl: avatarImageUrl,
resolution: knobs.resolution || "720p",
});
// Start polling for video generation status
onUpdateJob(sceneId, {
taskId: result.taskId,
status: "running",
progress: 5,
});
startPolling(result.taskId, sceneId);
} catch (error) {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
const message = error instanceof Error ? error.message : "Video generation failed";
onError(message);
} finally {
setRendering(null);
}
};
const getStatusColor = (status: Job["status"]) => {
switch (status) {
case "completed":
return "success";
case "failed":
return "error";
case "running":
case "previewing":
return "info";
default:
return "default";
}
};
const getStatusIcon = (status: Job["status"]) => {
switch (status) {
case "completed":
return <CheckCircleIcon />;
case "failed":
return <InfoIcon />;
case "running":
case "previewing":
return <CircularProgress size={16} />;
default:
return <RadioButtonUncheckedIcon />;
}
};
return ( return (
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
{/* Header */}
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}> <Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}> <SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
Back to Script Back to Script
@@ -349,204 +115,99 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({ projectId, script, kno
</Typography> </Typography>
</Stack> </Stack>
{/* Info Alert */}
<Alert severity="info" sx={{ mb: 3, background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}> <Alert severity="info" sx={{ mb: 3, background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
<Typography variant="body2"> <Typography variant="body2">
<strong>Audio Generation:</strong> Preview creates a quick sample to test voice and pacing. Full render generates the complete, production-ready audio file for your episode. <strong>Audio Generation:</strong> Preview creates a quick sample to test voice and pacing. Full render generates the complete, production-ready audio file for your episode.
</Typography> </Typography>
</Alert> </Alert>
{/* Summary Stats */}
<SummaryStats jobs={jobs} scenes={script.scenes} />
{/* Empty State */}
{jobs.length === 0 && script.scenes.length === 0 && (
<Paper
sx={{
p: 4,
textAlign: "center",
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
border: "2px dashed rgba(102, 126, 234, 0.3)",
borderRadius: 2,
}}
>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, mb: 1 }}>
No scenes to render
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mb: 3 }}>
Go back to the script editor to generate and approve scenes first.
</Typography>
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
Back to Script Editor
</SecondaryButton>
</Paper>
)}
{/* Guidance Panel */}
{script.scenes.length > 0 && <GuidancePanel scenes={script.scenes} />}
{/* Scene Cards */}
<Stack spacing={2}> <Stack spacing={2}>
{jobs.map((job) => { {script.scenes.map((scene) => {
const scene = getScene(job.sceneId); const job = jobs.find((j) => j.sceneId === scene.id);
const initials = job.title
.split(" ")
.slice(0, 2)
.map((s) => s[0])
.join("")
.toUpperCase();
return ( return (
<GlassyCard key={job.sceneId} sx={glassyCardSx}> <SceneCard
<Stack spacing={2}> key={scene.id}
<Stack direction="row" spacing={2} alignItems="flex-start"> scene={scene}
<Paper job={job}
sx={{ rendering={rendering}
width: 56, generatingImage={generatingImage}
height: 56, isBusy={isBusy}
display: "flex", avatarImageUrl={avatarImageUrl}
alignItems: "center", onRender={runRender}
justifyContent: "center", onImageGenerate={runImageGeneration}
background: alpha("#667eea", 0.2), onVideoRender={runVideoRender}
border: "1px solid rgba(102,126,234,0.3)", onDownloadAudio={handleDownloadAudio}
fontWeight: 700, onDownloadVideo={handleDownloadVideo}
fontSize: "1.2rem", onShare={handleShare}
}} onError={onError}
> />
{initials}
</Paper>
<Box flex={1}>
<Typography variant="h6" sx={{ mb: 0.5 }}>
{job.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip label={`Scene ${job.sceneId.slice(-4)}`} size="small" variant="outlined" />
{job.cost != null && (
<Chip
label={`$${job.cost.toFixed(2)}`}
size="small"
sx={{ background: alpha("#10b981", 0.2), color: "#6ee7b7" }}
title="Generation cost"
/>
)}
{job.fileSize && (
<Typography variant="caption" color="text.secondary">
{(job.fileSize / 1024).toFixed(1)} KB
</Typography>
)}
</Stack>
{job.finalUrl && (
<Button
size="small"
startIcon={<OpenInNewIcon />}
href={job.finalUrl}
target="_blank"
sx={{ mt: 1, color: "#a78bfa" }}
>
Download Final Audio
</Button>
)}
{job.videoUrl && (
<Button
size="small"
startIcon={<VideocamIcon />}
href={job.videoUrl}
target="_blank"
sx={{ mt: 1, ml: 1, color: "#a78bfa" }}
>
Download Video
</Button>
)}
</Box>
<Chip
icon={getStatusIcon(job.status)}
label={job.status.charAt(0).toUpperCase() + job.status.slice(1)}
color={getStatusColor(job.status)}
size="small"
sx={{
textTransform: "capitalize",
minWidth: 100,
}}
/>
</Stack>
{job.status !== "idle" && job.status !== "completed" && (
<Box>
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Progress
</Typography>
<Typography variant="caption" color="text.secondary">
{job.progress}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={job.progress}
sx={{
height: 8,
borderRadius: 4,
background: alpha("#fff", 0.1),
"& .MuiLinearProgress-bar": {
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
},
}}
/>
</Box>
)}
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
<Stack direction="row" spacing={1} justifyContent="flex-end">
{job.status === "idle" && (
<>
<SecondaryButton
onClick={() => runRender(job.sceneId, "preview")}
disabled={isBusy}
startIcon={<VolumeUpIcon />}
tooltip="Preview a sample to test voice and pacing before generating the full episode"
>
Preview Sample
</SecondaryButton>
<PrimaryButton
onClick={() => runRender(job.sceneId, "full")}
disabled={isBusy}
startIcon={<PlayArrowIcon />}
tooltip="Generate the complete, production-ready audio for this scene"
>
Generate Audio
</PrimaryButton>
</>
)}
{job.status === "completed" && (job.previewUrl || job.finalUrl) && (
<Stack spacing={1} sx={{ width: "100%" }}>
<InlineAudioPlayer audioUrl={job.finalUrl || job.previewUrl || ""} title={job.title} />
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Button
size="small"
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => {
const link = document.createElement("a");
link.href = job.finalUrl || job.previewUrl || "";
link.download = `${job.title.replace(/\s+/g, "-")}.mp3`;
link.click();
}}
>
Download
</Button>
<Button
size="small"
variant="outlined"
startIcon={<ShareIcon />}
onClick={async () => {
if (navigator.share && job.finalUrl) {
try {
await navigator.share({
title: job.title,
text: `Check out this podcast episode: ${job.title}`,
url: job.finalUrl,
});
} catch (err) {
// User cancelled or error
}
} else {
// Fallback: copy to clipboard
await navigator.clipboard.writeText(job.finalUrl || job.previewUrl || "");
alert("Audio URL copied to clipboard!");
}
}}
>
Share
</Button>
</Stack>
</Stack>
)}
{job.status === "failed" && (
<Button variant="outlined" color="warning" onClick={() => runRender(job.sceneId, "full")} startIcon={<RefreshIcon />}>
Retry
</Button>
)}
</Stack>
</Stack>
</GlassyCard>
); );
})} })}
</Stack> </Stack>
<Box sx={{ mt: 3, display: "flex", justifyContent: "flex-end" }}> {/* Footer - Video Generation Focus */}
<SecondaryButton onClick={onBack}>Done</SecondaryButton> <Paper
</Box> sx={{
mt: 4,
p: 3,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
border: "1px solid rgba(102, 126, 234, 0.15)",
borderRadius: 2,
}}
>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" spacing={2}>
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
Back to Script
</SecondaryButton>
{allScenesCompleted ? (
<Stack spacing={1} alignItems="flex-end">
<Typography variant="body1" sx={{ color: "#10b981", fontWeight: 700, fontSize: "1rem" }}>
🎉 All scenes ready for video generation!
</Typography>
<Typography variant="body2" sx={{ color: "#64748b" }}>
Generate videos for individual scenes or download them.
</Typography>
</Stack>
) : (
<Typography variant="body2" sx={{ color: "#64748b" }}>
Complete audio and image generation for all scenes to enable video generation.
</Typography>
)}
</Stack>
</Stack>
</Paper>
</Box> </Box>
); );
}; };

View File

@@ -0,0 +1,84 @@
import React from "react";
import { Box, Stack, Typography, Alert, Paper, alpha } from "@mui/material";
import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
import { Script } from "../types";
interface GuidancePanelProps {
scenes: Script["scenes"];
}
export const GuidancePanel: React.FC<GuidancePanelProps> = ({ scenes }) => {
const scenesNeedingAudio = scenes.filter((s) => !s.audioUrl).length;
const allScenesHaveAudio = scenes.length > 0 && scenesNeedingAudio === 0;
return (
<Paper
sx={{
p: 3,
mb: 3,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%)",
border: "2px solid rgba(102, 126, 234, 0.3)",
borderRadius: 2,
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.15)",
}}
>
<Stack spacing={2}>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1.5, fontSize: "1.125rem" }}>
<PlayArrowIcon sx={{ color: "#667eea", fontSize: "1.5rem" }} />
What's Next? Generate Audio for Your Scenes
</Typography>
<Stack spacing={1.5}>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7, fontSize: "0.9375rem" }}>
<strong>For each scene below:</strong>
</Typography>
<Box component="ul" sx={{ m: 0, pl: 2.5, color: "#475569" }}>
<Typography component="li" variant="body2" sx={{ mb: 1, lineHeight: 1.7 }}>
<strong>If audio is missing:</strong> Click <strong style={{ color: "#667eea" }}>"Generate Audio"</strong> to create the audio file for that scene
</Typography>
<Typography component="li" variant="body2" sx={{ mb: 1, lineHeight: 1.7 }}>
<strong>If audio exists:</strong> The scene is ready! You can download it or proceed to video generation
</Typography>
<Typography component="li" variant="body2" sx={{ lineHeight: 1.7 }}>
<strong>Optional:</strong> Use <strong>"Preview Sample"</strong> to test voice and pacing before full generation
</Typography>
</Box>
{scenesNeedingAudio > 0 && (
<Alert
severity="info"
sx={{
mt: 1,
background: alpha("#3b82f6", 0.1),
border: "1px solid rgba(59,130,246,0.3)",
"& .MuiAlert-icon": {
color: "#3b82f6",
},
}}
>
<Typography variant="body2" sx={{ color: "#1e40af", fontWeight: 600 }}>
📢 {scenesNeedingAudio} scene{scenesNeedingAudio !== 1 ? "s" : ""} need{scenesNeedingAudio === 1 ? "s" : ""} audio generation. Scroll down and click the <strong>"Generate Audio"</strong> buttons below!
</Typography>
</Alert>
)}
{allScenesHaveAudio && (
<Alert
severity="success"
sx={{
mt: 1,
background: alpha("#10b981", 0.1),
border: "1px solid rgba(16,185,129,0.3)",
"& .MuiAlert-icon": {
color: "#10b981",
},
}}
>
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 600 }}>
All scenes have audio! Your podcast is ready. You can download individual scenes or proceed to video generation.
</Typography>
</Alert>
)}
</Stack>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,179 @@
import React from "react";
import { Stack } from "@mui/material";
import {
VolumeUp as VolumeUpIcon,
PlayArrow as PlayArrowIcon,
Refresh as RefreshIcon,
Image as ImageIcon,
Videocam as VideocamIcon,
Download as DownloadIcon,
Share as ShareIcon,
} from "@mui/icons-material";
import { Scene, Job } from "../types";
import { PrimaryButton, SecondaryButton } from "../ui";
interface SceneActionButtonsProps {
scene: Scene;
job?: Job;
hasAudio: boolean;
hasImage: boolean;
hasVideo: boolean;
audioUrl: string;
rendering: string | null;
generatingImage: string | null;
isBusy: boolean;
onRender: (sceneId: string, mode: "preview" | "full") => void;
onImageGenerate: (sceneId: string) => void;
onVideoRender: (sceneId: string) => void;
onDownloadAudio: (audioUrl: string, title: string) => void;
onDownloadVideo: (videoUrl: string, title: string) => void;
onShare: (audioUrl: string, title: string) => void;
onError: (message: string) => void;
}
export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
scene,
job,
hasAudio,
hasImage,
hasVideo,
audioUrl,
rendering,
generatingImage,
isBusy,
onRender,
onImageGenerate,
onVideoRender,
onDownloadAudio,
onDownloadVideo,
onShare,
onError,
}) => {
const isGeneratingImage = generatingImage === scene.id;
const needsAudio = !hasAudio && (!job || job.status === "idle");
// No audio - show generate buttons
if (needsAudio) {
return (
<Stack direction="row" spacing={1} justifyContent="flex-end">
<SecondaryButton
onClick={() => onRender(scene.id, "preview")}
disabled={isBusy}
startIcon={<VolumeUpIcon />}
tooltip="Preview a sample to test voice and pacing"
>
Preview Sample
</SecondaryButton>
<PrimaryButton
onClick={() => onRender(scene.id, "full")}
disabled={isBusy}
startIcon={<PlayArrowIcon />}
tooltip="Generate the complete, production-ready audio for this scene"
>
Generate Audio
</PrimaryButton>
</Stack>
);
}
// Failed - show retry
if (job?.status === "failed") {
return (
<Stack direction="row" spacing={1} justifyContent="flex-end">
<SecondaryButton
onClick={() => onRender(scene.id, "full")}
startIcon={<RefreshIcon />}
tooltip="Retry audio generation"
>
Retry
</SecondaryButton>
</Stack>
);
}
// Has audio - show all action buttons
return (
<Stack direction="row" spacing={1.5} justifyContent="flex-end" flexWrap="wrap" useFlexGap>
{/* Generate Image */}
<PrimaryButton
onClick={() => onImageGenerate(scene.id)}
disabled={isGeneratingImage || hasImage}
loading={isGeneratingImage}
startIcon={<ImageIcon />}
tooltip={
hasImage
? "Image already generated for this scene"
: isGeneratingImage
? "Generating image..."
: "Generate image for video (optional)"
}
sx={{ minWidth: 160 }}
>
{isGeneratingImage ? "Generating..." : hasImage ? "Image Ready" : "Generate Image"}
</PrimaryButton>
{/* Generate Video */}
<PrimaryButton
onClick={() => onVideoRender(scene.id)}
disabled={isBusy || !hasImage || hasVideo}
startIcon={<VideocamIcon />}
tooltip={
hasVideo
? "Video already generated"
: !hasImage
? "Generate an image first to create video"
: isBusy
? "Another operation in progress"
: "Generate video for this scene"
}
sx={{ minWidth: 160 }}
>
{hasVideo ? "Video Ready" : "Generate Video"}
</PrimaryButton>
{/* Download Video */}
{hasVideo && job?.videoUrl && (
<SecondaryButton
onClick={() => onDownloadVideo(job.videoUrl!, scene.title)}
startIcon={<VideocamIcon />}
tooltip="Download video file"
>
Download Video
</SecondaryButton>
)}
{/* Download Audio */}
<SecondaryButton
onClick={() => {
if (!audioUrl) {
onError("Audio URL not found. Please regenerate audio.");
return;
}
onDownloadAudio(audioUrl, scene.title);
}}
startIcon={<DownloadIcon />}
tooltip={hasAudio ? "Download this scene's audio file" : "No audio available. Generate audio first."}
disabled={!hasAudio}
>
Download Audio
</SecondaryButton>
{/* Share */}
<SecondaryButton
onClick={() => {
if (!audioUrl) {
onError("Audio URL not found. Please regenerate audio.");
return;
}
onShare(audioUrl, scene.title);
}}
startIcon={<ShareIcon />}
tooltip={hasAudio ? "Share this scene's audio" : "No audio available. Generate audio first."}
disabled={!hasAudio}
>
Share
</SecondaryButton>
</Stack>
);
};

View File

@@ -0,0 +1,415 @@
import React, { useState, useEffect } from "react";
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, CircularProgress, alpha } from "@mui/material";
import {
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
Info as InfoIcon,
OpenInNew as OpenInNewIcon,
Videocam as VideocamIcon,
} from "@mui/icons-material";
import { Scene, Job } from "../types";
import { GlassyCard, glassyCardSx } from "../ui";
import { InlineAudioPlayer } from "../InlineAudioPlayer";
import { SceneActionButtons } from "./SceneActionButtons";
import { aiApiClient } from "../../../api/client";
interface SceneCardProps {
scene: Scene;
job?: Job;
rendering: string | null;
generatingImage: string | null;
isBusy: boolean;
avatarImageUrl?: string | null;
onRender: (sceneId: string, mode: "preview" | "full") => void;
onImageGenerate: (sceneId: string) => void;
onVideoRender: (sceneId: string) => void;
onDownloadAudio: (audioUrl: string, title: string) => void;
onDownloadVideo: (videoUrl: string, title: string) => void;
onShare: (audioUrl: string, title: string) => void;
onError: (message: string) => void;
}
const getInitials = (title: string): string => {
return title
.split(" ")
.slice(0, 2)
.map((s) => s[0])
.join("")
.toUpperCase();
};
const getStatusColor = (status: Job["status"]) => {
switch (status) {
case "completed":
return "success";
case "failed":
return "error";
case "running":
case "previewing":
return "info";
default:
return "default";
}
};
const getStatusIcon = (status: Job["status"]) => {
switch (status) {
case "completed":
return <CheckCircleIcon />;
case "failed":
return <InfoIcon />;
case "running":
case "previewing":
return <CircularProgress size={16} />;
default:
return <RadioButtonUncheckedIcon />;
}
};
export const SceneCard: React.FC<SceneCardProps> = ({
scene,
job,
rendering,
generatingImage,
isBusy,
avatarImageUrl,
onRender,
onImageGenerate,
onVideoRender,
onDownloadAudio,
onDownloadVideo,
onShare,
onError,
}) => {
const hasAudio = Boolean(scene.audioUrl || job?.finalUrl || job?.previewUrl);
const hasImage = Boolean(scene.imageUrl || job?.imageUrl);
const hasVideo = Boolean(job?.videoUrl);
const audioUrl = job?.finalUrl || job?.previewUrl || scene.audioUrl || "";
const imageUrl = job?.imageUrl || scene.imageUrl || "";
const status = job?.status || (hasAudio ? "completed" : "idle");
const initials = getInitials(scene.title);
// Load image as blob if it's an authenticated endpoint
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
useEffect(() => {
if (!imageUrl) {
setImageBlobUrl(null);
return;
}
console.log('[SceneCard] Loading image:', { imageUrl, hasImage, sceneId: scene.id });
// Check if this is a podcast image endpoint that requires authentication
const isPodcastImage = imageUrl.includes('/api/podcast/images/') || imageUrl.includes('/api/story/images/');
if (!isPodcastImage) {
// Regular URL (external), use directly
console.log('[SceneCard] Using external image URL directly');
setImageBlobUrl(imageUrl);
return;
}
// Fetch as blob for authenticated endpoints
let isMounted = true;
const currentImageUrl = imageUrl;
const loadImageBlob = async () => {
try {
// Normalize path
let imagePath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`;
// Convert /api/story/images/ to /api/podcast/images/ if needed
if (imagePath.includes('/api/story/images/')) {
const filename = imagePath.split('/api/story/images/').pop() || '';
imagePath = `/api/podcast/images/${filename}`;
}
// Ensure it's a podcast image endpoint
if (!imagePath.includes('/api/podcast/images/')) {
const filename = imagePath.split('/').pop() || currentImageUrl;
imagePath = `/api/podcast/images/${filename}`;
}
// Remove query parameters if present
imagePath = imagePath.split('?')[0];
console.log('[SceneCard] Fetching image blob from:', imagePath);
const response = await aiApiClient.get(imagePath, {
responseType: 'blob',
});
if (!isMounted || imageUrl !== currentImageUrl) {
console.log('[SceneCard] Component unmounted or URL changed, skipping blob URL set');
return;
}
const blob = response.data;
const newBlobUrl = URL.createObjectURL(blob);
console.log('[SceneCard] Image blob loaded successfully, created blob URL');
setImageBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== newBlobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return newBlobUrl;
});
} catch (err) {
console.error('[SceneCard] Failed to load image blob:', err);
if (isMounted && imageUrl === currentImageUrl) {
// Try adding query token as fallback
try {
// Normalize path again for fallback
let fallbackPath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`;
// Convert /api/story/images/ to /api/podcast/images/ if needed
if (fallbackPath.includes('/api/story/images/')) {
const filename = fallbackPath.split('/api/story/images/').pop() || '';
fallbackPath = `/api/podcast/images/${filename}`;
}
// Ensure it's a podcast image endpoint
if (!fallbackPath.includes('/api/podcast/images/')) {
const filename = fallbackPath.split('/').pop() || currentImageUrl;
fallbackPath = `/api/podcast/images/${filename}`;
}
// Remove query parameters if present
fallbackPath = fallbackPath.split('?')[0];
// Get auth token from localStorage or use aiApiClient's default token
const token = localStorage.getItem('clerk_dashboard_token') || '';
if (token) {
const urlWithToken = `${fallbackPath}?token=${encodeURIComponent(token)}`;
console.log('[SceneCard] Trying URL with query token');
setImageBlobUrl(urlWithToken);
} else {
// Fallback to original URL
console.log('[SceneCard] No token available, using original URL');
setImageBlobUrl(imageUrl);
}
} catch (fallbackErr) {
console.error('[SceneCard] Fallback also failed:', fallbackErr);
setImageBlobUrl(imageUrl);
}
}
}
};
loadImageBlob();
return () => {
isMounted = false;
// Cleanup blob URL when component unmounts or URL changes
setImageBlobUrl((prevBlobUrl) => {
if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return null;
});
};
}, [imageUrl, hasImage, scene.id]);
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2}>
{/* Header */}
<Stack direction="row" spacing={2} alignItems="flex-start">
<Paper
sx={{
width: 56,
height: 56,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: alpha("#667eea", 0.2),
border: "1px solid rgba(102,126,234,0.3)",
fontWeight: 700,
fontSize: "1.2rem",
}}
>
{initials}
</Paper>
<Box flex={1}>
<Typography variant="h6" sx={{ mb: 0.5, color: "#0f172a", fontWeight: 600 }}>
{scene.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip label={`Scene ${scene.id.slice(-4)}`} size="small" variant="outlined" />
{job?.cost != null && (
<Chip
label={`$${job.cost.toFixed(2)}`}
size="small"
sx={{ background: alpha("#10b981", 0.2), color: "#6ee7b7" }}
title="Generation cost"
/>
)}
{job?.fileSize && (
<Typography variant="caption" color="text.secondary">
{(job.fileSize / 1024).toFixed(1)} KB
</Typography>
)}
{!job && (
<Chip
label={hasAudio ? "Audio Ready" : "Needs Audio"}
size="small"
color={hasAudio ? "success" : "warning"}
sx={{
background: hasAudio ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
color: hasAudio ? "#059669" : "#d97706",
fontWeight: 600,
}}
/>
)}
</Stack>
{job?.finalUrl && (
<Box sx={{ mt: 1 }}>
<Box
component="a"
href={job.finalUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<OpenInNewIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">Download Final Audio</Typography>
</Box>
</Box>
)}
{hasVideo && job?.videoUrl && (
<Box sx={{ mt: 1 }}>
<Box
component="a"
href={job.videoUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<VideocamIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">Download Video</Typography>
</Box>
</Box>
)}
</Box>
{job && (
<Chip
icon={getStatusIcon(status)}
label={status.charAt(0).toUpperCase() + status.slice(1)}
color={getStatusColor(status)}
size="small"
sx={{
textTransform: "capitalize",
minWidth: 100,
}}
/>
)}
</Stack>
{/* Progress Bar */}
{job && job.status !== "idle" && job.status !== "completed" && (
<Box>
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Progress
</Typography>
<Typography variant="caption" color="text.secondary">
{job.progress}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={job.progress}
sx={{
height: 8,
borderRadius: 4,
background: alpha("#fff", 0.1),
"& .MuiLinearProgress-bar": {
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
},
}}
/>
</Box>
)}
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)" }} />
{/* Success Alert for Pre-generated Audio */}
{hasAudio && !job && (
<Alert severity="success" sx={{ width: "100%", background: alpha("#10b981", 0.1), border: "1px solid rgba(16,185,129,0.3)" }}>
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}>
Audio already generated in Script Editor. Ready to use!
</Typography>
</Alert>
)}
{/* Audio Player */}
{hasAudio && audioUrl && (
<InlineAudioPlayer audioUrl={audioUrl} title={scene.title} />
)}
{/* Image Preview */}
{hasImage && (imageBlobUrl || imageUrl) && (
<Box
sx={{
width: "100%",
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(102,126,234,0.2)",
background: alpha("#667eea", 0.05),
}}
>
<Box
component="img"
src={imageBlobUrl || imageUrl}
alt={scene.title}
sx={{
width: "100%",
height: "auto",
display: "block",
maxHeight: 400,
objectFit: "cover",
}}
onError={(e) => {
console.error('[SceneCard] Image failed to load:', {
src: e.currentTarget.src,
imageUrl,
imageBlobUrl,
hasImage,
});
}}
onLoad={() => {
console.log('[SceneCard] Image loaded successfully:', {
src: imageBlobUrl || imageUrl,
});
}}
/>
</Box>
)}
{/* Action Buttons */}
<SceneActionButtons
scene={scene}
job={job}
hasAudio={hasAudio}
hasImage={hasImage}
hasVideo={hasVideo}
audioUrl={audioUrl}
rendering={rendering}
generatingImage={generatingImage}
isBusy={isBusy}
onRender={onRender}
onImageGenerate={onImageGenerate}
onVideoRender={onVideoRender}
onDownloadAudio={onDownloadAudio}
onDownloadVideo={onDownloadVideo}
onShare={onShare}
onError={onError}
/>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,71 @@
import React from "react";
import { Box, Stack, Typography, Paper } from "@mui/material";
import { Script, Job } from "../types";
interface SummaryStatsProps {
jobs: Job[];
scenes: Script["scenes"];
}
export const SummaryStats: React.FC<SummaryStatsProps> = ({ jobs, scenes }) => {
const totalScenes = jobs.length > 0 ? jobs.length : scenes.length;
const readyToRender = jobs.length > 0
? jobs.filter((j) => j.status === "idle").length
: scenes.filter((s) => !s.audioUrl).length;
const completed = jobs.length > 0
? jobs.filter((j) => j.status === "completed").length
: scenes.filter((s) => s.audioUrl).length;
const inProgress = jobs.length > 0
? jobs.filter((j) => j.status === "running" || j.status === "previewing").length
: 0;
if (totalScenes === 0) return null;
return (
<Paper
sx={{
p: 2.5,
mb: 3,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)",
border: "1px solid rgba(102, 126, 234, 0.15)",
borderRadius: 2,
}}
>
<Stack direction="row" spacing={3} flexWrap="wrap" useFlexGap>
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, display: "block", mb: 0.5 }}>
Total Scenes
</Typography>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 700 }}>
{totalScenes}
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, display: "block", mb: 0.5 }}>
Ready to Render
</Typography>
<Typography variant="h6" sx={{ color: "#667eea", fontWeight: 700 }}>
{readyToRender}
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, display: "block", mb: 0.5 }}>
Completed
</Typography>
<Typography variant="h6" sx={{ color: "#10b981", fontWeight: 700 }}>
{completed}
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, display: "block", mb: 0.5 }}>
In Progress
</Typography>
<Typography variant="h6" sx={{ color: "#3b82f6", fontWeight: 700 }}>
{inProgress}
</Typography>
</Box>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,6 @@
export { SceneCard } from "./SceneCard";
export { SceneActionButtons } from "./SceneActionButtons";
export { SummaryStats } from "./SummaryStats";
export { GuidancePanel } from "./GuidancePanel";
export { useRenderQueue } from "./useRenderQueue";

View File

@@ -0,0 +1,376 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Script, Knobs, Job, RenderJobResult, TaskStatus } from "../types";
import { podcastApi } from "../../../services/podcastApi";
interface UseRenderQueueProps {
script: Script;
jobs: Job[];
knobs: Knobs;
projectId: string;
budgetCap?: number;
avatarImageUrl?: string | null;
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
onUpdateScript?: (script: Script) => void;
onError: (message: string) => void;
}
const getSceneVoiceEmotion = (knobs: Knobs) => knobs.voice_emotion || "neutral";
export const useRenderQueue = ({
script,
jobs,
knobs,
projectId,
budgetCap,
avatarImageUrl,
onUpdateJob,
onUpdateScript,
onError,
}: UseRenderQueueProps) => {
const [rendering, setRendering] = useState<string | null>(null);
const [generatingImage, setGeneratingImage] = useState<string | null>(null);
const [combiningAudio, setCombiningAudio] = useState(false);
const [combinedAudioResult, setCombinedAudioResult] = useState<{
url: string;
filename: string;
duration: number;
sceneCount: number;
} | null>(null);
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
// Cleanup polling intervals on unmount
useEffect(() => {
const intervals = pollingIntervals.current;
return () => {
intervals.forEach((interval) => clearInterval(interval));
intervals.clear();
};
}, []);
// Initialize jobs if empty
useEffect(() => {
if (jobs.length === 0 && script.scenes.length > 0) {
const initialJobs: Job[] = script.scenes.map((s) => {
const hasExistingAudio = Boolean(s.audioUrl);
return {
sceneId: s.id,
title: s.title,
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
progress: hasExistingAudio ? 100 : 0,
previewUrl: null,
finalUrl: hasExistingAudio ? s.audioUrl || null : null,
imageUrl: s.imageUrl || null, // Include existing imageUrl from scene
jobId: null,
};
});
initialJobs.forEach((job) => {
onUpdateJob(job.sceneId, job);
});
}
}, [script.scenes.length, jobs.length, onUpdateJob, script.scenes]);
const getScene = useCallback((sceneId: string) => script.scenes.find((s) => s.id === sceneId), [script.scenes]);
const pollTaskStatus = useCallback(async (taskId: string, sceneId: string) => {
try {
const status: TaskStatus = await podcastApi.pollTaskStatus(taskId);
onUpdateJob(sceneId, {
progress: status.progress ?? 0,
status: status.status === "completed" ? "completed" : status.status === "failed" ? "failed" : "running",
});
if (status.status === "completed" && status.result) {
const result = status.result;
onUpdateJob(sceneId, {
status: "completed",
progress: 100,
videoUrl: result.video_url,
cost: result.cost,
});
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
} else if (status.status === "failed") {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
onError(status.error || "Video generation failed");
}
return status.status === "completed" || status.status === "failed";
} catch (error) {
console.error("Error polling task status:", error);
return false;
}
}, [onUpdateJob, onError]);
const startPolling = useCallback((taskId: string, sceneId: string) => {
const existingInterval = pollingIntervals.current.get(sceneId);
if (existingInterval) {
clearInterval(existingInterval);
}
const interval = setInterval(async () => {
const isComplete = await pollTaskStatus(taskId, sceneId);
if (isComplete) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
}, 3000);
pollingIntervals.current.set(sceneId, interval);
}, [pollTaskStatus]);
const runRender = useCallback(async (sceneId: string, mode: "preview" | "full") => {
if (rendering && rendering !== sceneId) return;
const job = jobs.find((j) => j.sceneId === sceneId);
if (job && job.status !== "idle") return;
const scene = getScene(sceneId);
if (!scene) return;
const textLength = scene.lines.map((l) => l.text).join(" ").length;
const estimatedCost = (textLength / 1000) * 0.05;
if (budgetCap && budgetCap > 0) {
const currentSpent = jobs
.filter((j) => j.status === "completed" && j.cost)
.reduce((sum, j) => sum + (j.cost || 0), 0);
if (currentSpent + estimatedCost > budgetCap) {
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(4)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
return;
}
}
setRendering(sceneId);
onUpdateJob(sceneId, {
status: mode === "preview" ? "previewing" : "running",
progress: mode === "preview" ? 25 : 40,
});
try {
const result: RenderJobResult = await podcastApi.renderSceneAudio({
scene,
voiceId: "Wise_Woman",
emotion: scene.emotion || getSceneVoiceEmotion(knobs),
speed: knobs.voice_speed,
});
const updates: Partial<Job> = {
status: "completed",
progress: 100,
cost: result.cost,
provider: result.provider,
voiceId: result.voiceId,
fileSize: result.fileSize,
};
if (mode === "preview") {
updates.previewUrl = result.audioUrl;
window.open(result.audioUrl, "_blank");
} else {
updates.finalUrl = result.audioUrl;
try {
await podcastApi.saveAudioToAssetLibrary({
audioUrl: result.audioUrl,
filename: result.audioFilename,
title: `${scene.title} - ${projectId}`,
description: `Podcast episode scene audio: ${scene.title}`,
projectId,
sceneId,
cost: result.cost,
provider: result.provider,
model: result.model,
fileSize: result.fileSize,
});
} catch (assetError) {
console.error("Failed to save to asset library:", assetError);
}
}
onUpdateJob(sceneId, updates);
} catch (error) {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
const message = error instanceof Error ? error.message : "Render failed";
onError(message);
} finally {
setRendering(null);
}
}, [rendering, jobs, getScene, knobs, budgetCap, projectId, onUpdateJob, onError]);
const runImageGeneration = useCallback(async (sceneId: string) => {
if (generatingImage && generatingImage !== sceneId) return;
const scene = getScene(sceneId);
if (!scene) return;
setGeneratingImage(sceneId);
try {
const sceneContent = scene.lines.map((line) => line.text).join(" ");
const result = await podcastApi.generateSceneImage({
sceneId: scene.id,
sceneTitle: scene.title,
sceneContent: sceneContent,
width: 1024,
height: 1024,
});
// Update job with image URL
onUpdateJob(sceneId, {
imageUrl: result.image_url,
});
// Also update the scene's imageUrl so it persists
if (onUpdateScript) {
const updatedScenes = script.scenes.map((s) =>
s.id === sceneId ? { ...s, imageUrl: result.image_url } : s
);
onUpdateScript({ ...script, scenes: updatedScenes });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Image generation failed";
onError(message);
} finally {
setGeneratingImage(null);
}
}, [generatingImage, getScene, onUpdateJob, onError]);
const runVideoRender = useCallback(async (sceneId: string) => {
if (rendering && rendering !== sceneId) return;
const scene = getScene(sceneId);
if (!scene) return;
const sceneImageUrl = scene.imageUrl || avatarImageUrl;
if (!sceneImageUrl) {
onError("Scene image is required for video generation. Please generate images for scenes first.");
return;
}
const job = jobs.find((j) => j.sceneId === sceneId);
if (!job?.finalUrl) {
onError("Please generate audio first before creating video.");
return;
}
const estimatedCost = 0.30;
if (budgetCap && budgetCap > 0) {
const currentSpent = jobs
.filter((j) => j.status === "completed" && j.cost)
.reduce((sum, j) => sum + (j.cost || 0), 0);
if (currentSpent + estimatedCost > budgetCap) {
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(2)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
return;
}
}
setRendering(sceneId);
onUpdateJob(sceneId, {
status: "running",
progress: 5,
});
try {
const result = await podcastApi.generateVideo({
projectId,
sceneId,
sceneTitle: scene.title,
audioUrl: job.finalUrl,
avatarImageUrl: sceneImageUrl,
resolution: knobs.resolution || "720p",
});
onUpdateJob(sceneId, {
taskId: result.taskId,
status: "running",
progress: 5,
});
startPolling(result.taskId, sceneId);
} catch (error) {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
const message = error instanceof Error ? error.message : "Video generation failed";
onError(message);
} finally {
setRendering(null);
}
}, [rendering, getScene, avatarImageUrl, jobs, budgetCap, projectId, knobs, onUpdateJob, onError, startPolling]);
const combineAudio = useCallback(async () => {
try {
setCombiningAudio(true);
const sceneIds: string[] = [];
const sceneAudioUrls: string[] = [];
script.scenes.forEach((scene) => {
if (scene.audioUrl) {
// Ensure we're using the correct URL format (not blob URLs)
const audioUrl = scene.audioUrl.startsWith('blob:') ? '' : scene.audioUrl;
if (audioUrl) {
sceneIds.push(scene.id);
sceneAudioUrls.push(audioUrl);
}
}
});
jobs.forEach((job) => {
// Prefer finalUrl over previewUrl, and ensure it's not a blob URL
const audioUrl = job.finalUrl || job.previewUrl;
if (audioUrl && !audioUrl.startsWith('blob:') && !sceneAudioUrls.includes(audioUrl)) {
sceneIds.push(job.sceneId);
sceneAudioUrls.push(audioUrl);
}
});
if (sceneIds.length === 0) {
onError("No audio files found to combine.");
return;
}
const result = await podcastApi.combineAudio({
projectId,
sceneIds,
sceneAudioUrls,
});
// Store combined audio result for preview
setCombinedAudioResult({
url: result.combined_audio_url,
filename: result.combined_audio_filename,
duration: result.total_duration,
sceneCount: result.scene_count,
});
// Auto-download the combined audio
const link = document.createElement("a");
link.href = result.combined_audio_url;
link.download = `podcast-episode-${projectId.slice(-8)}.mp3`;
link.click();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to combine audio";
onError(`Failed to combine audio: ${message}`);
} finally {
setCombiningAudio(false);
}
}, [script.scenes, jobs, projectId, onError]);
return {
rendering,
generatingImage,
combiningAudio,
combinedAudioResult,
isBusy: Boolean(rendering),
runRender,
runImageGeneration,
runVideoRender,
combineAudio,
};
};

View File

@@ -1,19 +1,16 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Stack, Box, Typography, TextField, Button, Chip, CircularProgress, alpha } from "@mui/material"; import { Stack, Box, Typography, TextField, Button, Chip, alpha } from "@mui/material";
import { VolumeUp as VolumeUpIcon } from "@mui/icons-material";
import { Line } from "../types"; import { Line } from "../types";
import { GlassyCard, glassyCardSx } from "../ui"; import { GlassyCard, glassyCardSx } from "../ui";
interface LineEditorProps { interface LineEditorProps {
line: Line; line: Line;
onChange: (l: Line) => void; onChange: (l: Line) => void;
onPreview: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
} }
export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPreview }) => { export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange }) => {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [text, setText] = useState(line.text); const [text, setText] = useState(line.text);
const [previewing, setPreviewing] = useState(false);
useEffect(() => setText(line.text), [line.text]); useEffect(() => setText(line.text), [line.text]);
const handleSave = () => { const handleSave = () => {
@@ -21,33 +18,37 @@ export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPrevie
setEditing(false); setEditing(false);
}; };
const handlePreview = async () => {
setPreviewing(true);
try {
const res = await onPreview(text);
if (res.audioUrl) {
window.open(res.audioUrl, "_blank");
} else {
alert(res.message);
}
} finally {
setPreviewing(false);
}
};
return ( return (
<GlassyCard <GlassyCard
whileHover={{ y: -2 }} whileHover={{ y: -2 }}
sx={{ sx={{
...glassyCardSx, ...glassyCardSx,
p: 2, p: 2.5,
transition: "all 0.2s", transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
borderLeft: "3px solid transparent",
"&:hover": {
borderLeftColor: "#667eea",
boxShadow: "0 4px 6px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(15, 23, 42, 0.06)",
},
}} }}
> >
<Stack spacing={2}> <Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start"> <Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box flex={1}> <Box flex={1}>
<Chip label={line.speaker} size="small" sx={{ mb: 1, background: alpha("#667eea", 0.2), color: "#a78bfa" }} /> <Chip
label={line.speaker}
size="small"
sx={{
mb: 1.5,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
color: "#667eea",
fontWeight: 600,
fontSize: "0.75rem",
height: 24,
border: "1px solid rgba(102, 126, 234, 0.2)",
boxShadow: "0 1px 2px rgba(102, 126, 234, 0.05)",
}}
/>
{editing ? ( {editing ? (
<TextField <TextField
fullWidth fullWidth
@@ -57,47 +58,97 @@ export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPrevie
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
sx={{ sx={{
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
color: "white", color: "#0f172a",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" }, backgroundColor: "#f8fafc",
borderRadius: 2,
"& fieldset": {
borderColor: "rgba(15, 23, 42, 0.12)",
borderWidth: 1.5,
},
"&:hover fieldset": {
borderColor: "rgba(102, 126, 234, 0.4)",
},
"&.Mui-focused fieldset": {
borderColor: "#667eea",
borderWidth: 2,
},
},
"& .MuiInputBase-input": {
color: "#0f172a",
fontWeight: 400,
fontSize: "0.9375rem",
lineHeight: 1.6,
}, },
}} }}
/> />
) : ( ) : (
<Typography variant="body2" sx={{ lineHeight: 1.7, color: "rgba(255,255,255,0.9)" }}> <Typography
variant="body2"
sx={{
lineHeight: 1.75,
color: "#0f172a",
fontWeight: 400,
fontSize: "0.9375rem",
letterSpacing: "0.01em",
}}
>
{line.text} {line.text}
</Typography> </Typography>
)} )}
{line.usedFactIds && line.usedFactIds.length > 0 && ( {line.usedFactIds && line.usedFactIds.length > 0 && (
<Stack direction="row" spacing={0.5} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap> <Stack direction="row" spacing={0.5} sx={{ mt: 1.5 }} flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.75rem" }}>
Facts: Facts:
</Typography> </Typography>
{line.usedFactIds.map((id) => ( {line.usedFactIds.map((id) => (
<Chip key={id} label={id} size="small" variant="outlined" sx={{ fontSize: "0.65rem", height: 20 }} /> <Chip
key={id}
label={id}
size="small"
variant="outlined"
sx={{
fontSize: "0.6875rem",
height: 22,
color: "#64748b",
borderColor: "rgba(15, 23, 42, 0.12)",
fontWeight: 500,
}}
/>
))} ))}
</Stack> </Stack>
)} )}
</Box> </Box>
<Stack spacing={1} sx={{ ml: 2 }}> <Box sx={{ ml: 2 }}>
<Button <Button
size="small" size="small"
variant={editing ? "contained" : "outlined"} variant={editing ? "contained" : "outlined"}
onClick={editing ? handleSave : () => setEditing(true)} onClick={editing ? handleSave : () => setEditing(true)}
sx={{ minWidth: 80 }} sx={{
minWidth: 85,
color: editing ? "white" : "#667eea",
borderColor: editing ? "transparent" : "#667eea",
backgroundColor: editing
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: "transparent",
fontWeight: 600,
fontSize: "0.8125rem",
textTransform: "none",
borderRadius: 2,
borderWidth: editing ? 0 : 1.5,
boxShadow: editing ? "0 2px 4px rgba(102, 126, 234, 0.2)" : "none",
"&:hover": {
borderColor: editing ? "transparent" : "#5568d3",
backgroundColor: editing
? "linear-gradient(135deg, #5568d3 0%, #6b3fa0 100%)"
: alpha("#667eea", 0.08),
boxShadow: editing ? "0 4px 8px rgba(102, 126, 234, 0.3)" : "none",
},
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
}}
> >
{editing ? "Save" : "Edit"} {editing ? "Save" : "Edit"}
</Button> </Button>
<Button </Box>
size="small"
variant="outlined"
startIcon={previewing ? <CircularProgress size={14} /> : <VolumeUpIcon />}
onClick={handlePreview}
disabled={previewing || editing}
sx={{ minWidth: 120 }}
>
Preview TTS
</Button>
</Stack>
</Stack> </Stack>
</Stack> </Stack>
</GlassyCard> </GlassyCard>

View File

@@ -1,84 +1,377 @@
import React from "react"; import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, Divider, Chip, alpha } from "@mui/material"; import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress } from "@mui/material";
import { import {
EditNote as EditNoteIcon, EditNote as EditNoteIcon,
CheckCircle as CheckCircleIcon, CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon, RadioButtonUnchecked as RadioButtonUncheckedIcon,
VolumeUp as VolumeUpIcon,
PlayArrow as PlayArrowIcon,
Image as ImageIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { Scene, Line } from "../types"; import { Scene, Line, Knobs } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui"; import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { LineEditor } from "./LineEditor"; import { LineEditor } from "./LineEditor";
import { podcastApi } from "../../../services/podcastApi";
import { aiApiClient } from "../../../api/client";
interface SceneEditorProps { interface SceneEditorProps {
scene: Scene; scene: Scene;
onUpdateScene: (s: Scene) => void; onUpdateScene: (s: Scene) => void;
onApprove: (id: string) => Promise<void>; onApprove: (id: string) => Promise<void>;
onPreviewLine: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>; knobs: Knobs;
approvingSceneId?: string | null; approvingSceneId?: string | null;
generatingAudioId?: string | null;
onAudioGenerationStart?: (sceneId: string) => void;
onAudioGenerated?: (sceneId: string, audioUrl: string) => void;
idea?: string; // Podcast idea for image generation context
} }
export const SceneEditor: React.FC<SceneEditorProps> = ({ export const SceneEditor: React.FC<SceneEditorProps> = ({
scene, scene,
onUpdateScene, onUpdateScene,
onApprove, onApprove,
onPreviewLine, knobs,
approvingSceneId, approvingSceneId,
generatingAudioId,
onAudioGenerationStart,
onAudioGenerated,
idea,
}) => { }) => {
const [localGenerating, setLocalGenerating] = useState(false);
const [generatingImage, setGeneratingImage] = useState(false);
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
// Load audio as blob when audioUrl is available
useEffect(() => {
if (!scene.audioUrl) {
// Clean up blob URL if audioUrl is removed
setAudioBlobUrl((currentBlobUrl) => {
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
}
return null;
});
return;
}
let isMounted = true;
const currentAudioUrl = scene.audioUrl; // Capture current value
const loadAudioBlob = async () => {
try {
// Normalize path
let audioPath = currentAudioUrl.startsWith('/') ? currentAudioUrl : `/${currentAudioUrl}`;
// Convert /api/story/audio/ to /api/podcast/audio/ if needed
if (audioPath.includes('/api/story/audio/')) {
const filename = audioPath.split('/api/story/audio/').pop() || '';
audioPath = `/api/podcast/audio/${filename}`;
}
// Ensure it's a podcast audio endpoint
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || currentAudioUrl;
audioPath = `/api/podcast/audio/${filename}`;
}
// Remove query parameters if present
audioPath = audioPath.split('?')[0];
const response = await aiApiClient.get(audioPath, {
responseType: 'blob',
});
if (!isMounted) {
// Component unmounted or audioUrl changed, don't set blob URL
return;
}
// Double-check that audioUrl hasn't changed
if (scene.audioUrl !== currentAudioUrl) {
return;
}
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
setAudioBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== blobUrl) {
URL.revokeObjectURL(prevBlobUrl);
}
return blobUrl;
});
} catch (error) {
console.error(`Failed to load audio blob for scene ${scene.id}:`, error);
// Don't set blob URL on error - will show error state
}
};
loadAudioBlob();
// Cleanup: only mark as unmounted, don't revoke blob URL here
// The blob URL will be cleaned up when audioUrl changes (new effect) or component unmounts
return () => {
isMounted = false;
};
}, [scene.audioUrl, scene.id]);
const updateLine = (updatedLine: Line) => { const updateLine = (updatedLine: Line) => {
const updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) }; const updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) };
onUpdateScene(updated); onUpdateScene(updated);
}; };
const approving = approvingSceneId === scene.id; const approving = approvingSceneId === scene.id;
const generating = generatingAudioId === scene.id || localGenerating;
const hasAudio = Boolean(scene.audioUrl && audioBlobUrl);
const hasImage = Boolean(scene.imageUrl);
const handleApprove = async () => { const handleApproveAndGenerate = async () => {
await onApprove(scene.id); const wasAlreadyApproved = scene.approved;
onUpdateScene({ ...scene, approved: true }); const sceneId = scene.id;
try {
// Set generating state
setLocalGenerating(true);
if (onAudioGenerationStart) {
onAudioGenerationStart(sceneId);
}
// If scene is not approved yet, approve it first
// This will update the parent script state
if (!scene.approved) {
await onApprove(sceneId);
// The parent's approveScene already updated the script state
// We need to wait for React to propagate the updated scene prop
// For now, we'll update it locally too to ensure UI updates immediately
onUpdateScene({ ...scene, approved: true });
}
// Use the current scene (which should now be approved)
// If scene prop hasn't updated yet, use the local update we just made
const currentScene = { ...scene, approved: true };
// Generate audio
const result = await podcastApi.renderSceneAudio({
scene: currentScene,
voiceId: "Wise_Woman",
emotion: scene.emotion || knobs.voice_emotion || "neutral",
speed: knobs.voice_speed || 1.0,
});
// Update scene with audio URL and ensure approved state
// This will sync with parent script state
const updatedScene = { ...currentScene, audioUrl: result.audioUrl, approved: true };
onUpdateScene(updatedScene);
if (onAudioGenerated) {
onAudioGenerated(sceneId, result.audioUrl);
}
} catch (error) {
console.error("Failed to approve and generate audio:", error);
// On error, revert approval only if we just approved it in this call
if (!wasAlreadyApproved) {
onUpdateScene({ ...scene, approved: false, audioUrl: undefined });
}
throw error;
} finally {
setLocalGenerating(false);
}
};
const handleGenerateImage = async () => {
const sceneId = scene.id;
try {
setGeneratingImage(true);
// Build scene content from lines for context
const sceneContent = scene.lines.map((line) => line.text).join(" ");
const result = await podcastApi.generateSceneImage({
sceneId: scene.id,
sceneTitle: scene.title,
sceneContent: sceneContent,
idea: idea,
width: 1024,
height: 1024,
});
// Update scene with image URL
const updatedScene = { ...scene, imageUrl: result.image_url };
onUpdateScene(updatedScene);
} catch (error) {
console.error("Failed to generate image:", error);
throw error;
} finally {
setGeneratingImage(false);
}
}; };
return ( return (
<GlassyCard sx={glassyCardSx}> <GlassyCard sx={glassyCardSx}>
<Stack spacing={2}> <Stack spacing={2.5}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start"> <Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box> <Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}> <Typography
<EditNoteIcon fontSize="small" /> variant="h6"
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
mb: 1,
color: "#0f172a",
fontWeight: 600,
fontSize: "1.25rem",
letterSpacing: "-0.01em",
}}
>
<EditNoteIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1.5rem" }} />
{scene.title} {scene.title}
</Typography> </Typography>
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
<Chip <Chip
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />} icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
label={scene.approved ? "Approved" : "Pending Approval"} label={scene.approved ? "Approved" : "Pending Approval"}
size="small" size="small"
color={scene.approved ? "success" : "warning"} color={scene.approved ? "success" : "warning"}
sx={{ sx={{
background: scene.approved ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2), background: scene.approved
color: scene.approved ? "#6ee7b7" : "#fbbf24", ? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)"
border: scene.approved ? "1px solid rgba(16,185,129,0.3)" : "1px solid rgba(245,158,11,0.3)", : "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)",
}} }}
/> />
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem" }}>
Duration: {scene.duration}s Duration: {scene.duration}s
</Typography> </Typography>
</Stack> </Stack>
</Box> </Box>
<PrimaryButton <Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
onClick={handleApprove} <PrimaryButton
disabled={scene.approved || approving} onClick={handleApproveAndGenerate}
loading={approving} disabled={approving || generating}
startIcon={scene.approved ? <CheckCircleIcon /> : undefined} loading={approving || generating}
tooltip={scene.approved ? "Scene is approved and ready for rendering" : "Approve this scene to enable rendering"} startIcon={
> hasAudio && !generating ? (
{scene.approved ? "Approved" : approving ? "Approving..." : "Approve Scene"} <VolumeUpIcon />
</PrimaryButton> ) : generating ? (
<CircularProgress size={16} sx={{ color: "white" }} />
) : (
<PlayArrowIcon />
)
}
tooltip={
hasAudio && !generating
? "Regenerate audio for this scene"
: generating
? "Generating audio..."
: scene.approved
? "Generate audio for this scene"
: "Approve scene and generate audio"
}
sx={{
minWidth: 200,
}}
>
{hasAudio && !generating
? "Regenerate Audio"
: generating
? "Generating Audio..."
: scene.approved
? "Generate Audio"
: "Approve & Generate Audio"}
</PrimaryButton>
<PrimaryButton
onClick={handleGenerateImage}
disabled={generatingImage}
loading={generatingImage}
startIcon={
hasImage && !generatingImage ? (
<ImageIcon />
) : generatingImage ? (
<CircularProgress size={16} sx={{ color: "white" }} />
) : (
<ImageIcon />
)
}
tooltip={
hasImage
? "Regenerate image for this scene"
: generatingImage
? "Generating image..."
: "Generate image for video (optional)"
}
sx={{
minWidth: 180,
background: hasImage
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"&:hover": {
background: hasImage
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
},
}}
>
{hasImage && !generatingImage
? "Regenerate Image"
: generatingImage
? "Generating Image..."
: "Generate Image"}
</PrimaryButton>
</Stack>
</Stack> </Stack>
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} /> <Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1 }} />
<Stack spacing={2}> <Stack spacing={2}>
{scene.lines.map((line) => ( {scene.lines.map((line) => (
<LineEditor key={line.id} line={line} onChange={updateLine} onPreview={(text) => onPreviewLine(text)} /> <LineEditor key={line.id} line={line} onChange={updateLine} />
))} ))}
</Stack> </Stack>
{scene.audioUrl && (
<>
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1, mt: 1 }} />
<Box
sx={{
p: 2,
background: hasAudio
? "linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.08) 100%)"
: "linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(217, 119, 6, 0.08) 100%)",
borderRadius: 2,
border: hasAudio
? "1px solid rgba(16, 185, 129, 0.2)"
: "1px solid rgba(245, 158, 11, 0.2)",
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
<VolumeUpIcon sx={{ color: hasAudio ? "#059669" : "#d97706", fontSize: "1.25rem" }} />
<Typography variant="subtitle2" sx={{ color: hasAudio ? "#059669" : "#d97706", fontWeight: 600 }}>
{hasAudio ? "Audio Generated" : "Loading Audio..."}
</Typography>
</Stack>
{hasAudio && audioBlobUrl ? (
<audio controls style={{ width: "100%", borderRadius: 8 }}>
<source src={audioBlobUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
) : (
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", py: 2 }}>
<CircularProgress size={24} sx={{ color: "#d97706" }} />
</Box>
)}
</Box>
</>
)}
</Stack> </Stack>
</GlassyCard> </GlassyCard>
); );

View File

@@ -1,11 +1,13 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState, useCallback } from "react";
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha } from "@mui/material"; import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, Collapse, IconButton, Divider } from "@mui/material";
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon } from "@mui/icons-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 } from "@mui/icons-material";
import { Script, Knobs, Scene } from "../types"; import { Script, Knobs, Scene } from "../types";
import { BlogResearchResponse } from "../../../services/blogWriterApi"; import { BlogResearchResponse } from "../../../services/blogWriterApi";
import { podcastApi } from "../../../services/podcastApi"; import { podcastApi } from "../../../services/podcastApi";
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui"; import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
import { SceneEditor } from "./SceneEditor"; import { SceneEditor } from "./SceneEditor";
import { InlineAudioPlayer } from "../InlineAudioPlayer";
import { aiApiClient } from "../../../api/client";
interface ScriptEditorProps { interface ScriptEditorProps {
projectId: string; projectId: string;
@@ -40,6 +42,15 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null); const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(true);
const [combiningAudio, setCombiningAudio] = useState(false);
const [combinedAudioResult, setCombinedAudioResult] = useState<{
url: string;
filename: string;
duration: number;
sceneCount: number;
} | null>(null);
// Sync with parent state // Sync with parent state
useEffect(() => { useEffect(() => {
@@ -90,26 +101,32 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes]); }, [projectId, rawResearch, idea, knobs, speakers, durationMinutes]);
const updateScene = (updated: Scene) => { const updateScene = (updated: Scene) => {
if (!script) return; // Use functional update to ensure we're working with latest state
const updatedScript = { ...script, scenes: script.scenes.map((s) => (s.id === updated.id ? updated : s)) }; setScript((currentScript) => {
setScript(updatedScript); if (!currentScript) return currentScript;
onScriptChange(updatedScript); const updatedScript = {
...currentScript,
scenes: currentScript.scenes.map((s) => (s.id === updated.id ? { ...s, ...updated } : s))
};
onScriptChange(updatedScript);
return updatedScript;
});
}; };
const approveScene = async (sceneId: string) => { const approveScene = async (sceneId: string) => {
try { try {
setApprovingSceneId(sceneId); setApprovingSceneId(sceneId);
await podcastApi.approveScene({ projectId, sceneId }); await podcastApi.approveScene({ projectId, sceneId });
const updatedScript = script // Use functional update to ensure we're working with latest state
? { setScript((currentScript) => {
...script, if (!currentScript) return currentScript;
scenes: script.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)), const updatedScript = {
} ...currentScript,
: null; scenes: currentScript.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
if (updatedScript) { };
setScript(updatedScript);
onScriptChange(updatedScript); onScriptChange(updatedScript);
} return updatedScript;
});
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Failed to approve scene"; const message = err instanceof Error ? err.message : "Failed to approve scene";
setError(message); setError(message);
@@ -123,47 +140,405 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
const allApproved = script && script.scenes.every((s) => s.approved); const allApproved = script && script.scenes.every((s) => s.approved);
const approvedCount = script ? script.scenes.filter((s) => s.approved).length : 0; const approvedCount = script ? script.scenes.filter((s) => s.approved).length : 0;
const totalScenes = script ? script.scenes.length : 0; const totalScenes = script ? script.scenes.length : 0;
// Check if all scenes have both audio and images (required for video rendering)
const allScenesHaveAudioAndImages = script && script.scenes.every((s) => s.audioUrl && s.imageUrl);
const scenesWithAudio = script ? script.scenes.filter((s) => s.audioUrl).length : 0;
const allScenesHaveAudio = script && script.scenes.every((s) => s.audioUrl);
const combineAudio = useCallback(async () => {
if (!script || !projectId) return;
try {
setCombiningAudio(true);
const sceneIds: string[] = [];
const sceneAudioUrls: string[] = [];
script.scenes.forEach((scene) => {
if (scene.audioUrl) {
// Ensure we're using the correct URL format (not blob URLs)
const audioUrl = scene.audioUrl.startsWith('blob:') ? '' : scene.audioUrl;
if (audioUrl) {
sceneIds.push(scene.id);
sceneAudioUrls.push(audioUrl);
}
}
});
if (sceneIds.length === 0) {
onError("No audio files found to combine.");
return;
}
const result = await podcastApi.combineAudio({
projectId,
sceneIds,
sceneAudioUrls,
});
// Store combined audio result for preview
setCombinedAudioResult({
url: result.combined_audio_url,
filename: result.combined_audio_filename,
duration: result.total_duration,
sceneCount: result.scene_count,
});
// Download the combined audio as blob (for authenticated endpoints)
try {
// Normalize path
let audioPath = result.combined_audio_url.startsWith('/')
? result.combined_audio_url
: `/${result.combined_audio_url}`;
// Ensure it's a podcast audio endpoint
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || result.combined_audio_filename;
audioPath = `/api/podcast/audio/${filename}`;
}
// Remove query parameters if present
audioPath = audioPath.split('?')[0];
// Fetch as blob using authenticated client
const response = await aiApiClient.get(audioPath, {
responseType: 'blob',
});
// Create blob URL and download
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = result.combined_audio_filename || `podcast-episode-${projectId.slice(-8)}.mp3`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up blob URL after a delay
setTimeout(() => {
URL.revokeObjectURL(blobUrl);
}, 100);
} catch (downloadError) {
console.error('Failed to download combined audio:', downloadError);
onError('Failed to download audio file. You can try downloading again from the preview.');
}
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to combine audio";
onError(`Failed to combine audio: ${message}`);
} finally {
setCombiningAudio(false);
}
}, [script, projectId, onError]);
return ( return (
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 4 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}> <Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}> <SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
Back to Research Back to Research
</SecondaryButton> </SecondaryButton>
<Typography <Box sx={{ flex: 1 }}>
variant="h4" <Typography
sx={{ variant="h4"
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)", sx={{
WebkitBackgroundClip: "text", background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
WebkitTextFillColor: "transparent", WebkitBackgroundClip: "text",
fontWeight: 800, WebkitTextFillColor: "transparent",
display: "flex", fontWeight: 700,
alignItems: "center", letterSpacing: "-0.02em",
gap: 1, display: "flex",
}} alignItems: "center",
> gap: 1.5,
<EditNoteIcon /> fontSize: { xs: "1.75rem", md: "2rem" },
Script Editor }}
</Typography> >
<EditNoteIcon sx={{ fontSize: "2rem" }} />
Script Editor
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
Review and refine your podcast script before rendering
</Typography>
</Box>
</Stack> </Stack>
{loading && ( {loading && (
<Alert severity="info" icon={<CircularProgress size={20} />} sx={{ mb: 3 }}> <Alert
<Typography variant="body2">Generating script with AI... This may take a moment.</Typography> severity="info"
icon={<CircularProgress size={20} />}
sx={{
mb: 3,
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%)",
border: "1px solid rgba(99, 102, 241, 0.2)",
borderRadius: 2,
boxShadow: "0 1px 2px rgba(99, 102, 241, 0.05)",
"& .MuiAlert-icon": {
color: "#6366f1",
},
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 500 }}>
Generating script with AI... This may take a moment.
</Typography>
</Alert> </Alert>
)} )}
{error && ( {error && (
<Alert severity="error" sx={{ mb: 3 }}> <Alert
{error} severity="error"
sx={{
mb: 3,
background: "linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%)",
border: "1px solid rgba(239, 68, 68, 0.2)",
borderRadius: 2,
boxShadow: "0 1px 2px rgba(239, 68, 68, 0.05)",
"& .MuiAlert-icon": {
color: "#ef4444",
},
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 500 }}>
{error}
</Typography>
</Alert> </Alert>
)} )}
{script && ( {script && (
<Stack spacing={3}> <Stack spacing={3}>
<Alert severity="info" sx={{ background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}> {/* Script Format Explanation Panel */}
<Typography variant="body2"> <Paper
<strong>Approval Required:</strong> Each scene must be approved before rendering. Review and edit lines as needed, then approve each scene. 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 followjust 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={{
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%)",
border: "1px solid rgba(99, 102, 241, 0.2)",
borderRadius: 2,
boxShadow: "0 1px 2px rgba(99, 102, 241, 0.05)",
"& .MuiAlert-icon": {
color: "#6366f1",
},
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 500, lineHeight: 1.6 }}>
<strong style={{ fontWeight: 600 }}>Approval Required:</strong> Each scene must be approved before rendering. Review and edit lines as needed, then approve each scene.
</Typography> </Typography>
</Alert> </Alert>
@@ -179,8 +554,27 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
scene={scene} scene={scene}
onUpdateScene={updateScene} onUpdateScene={updateScene}
onApprove={approveScene} onApprove={approveScene}
onPreviewLine={(text) => podcastApi.previewLine(text)} knobs={knobs}
approvingSceneId={approvingSceneId} approvingSceneId={approvingSceneId}
generatingAudioId={generatingAudioId}
onAudioGenerationStart={(sceneId) => {
setGeneratingAudioId(sceneId);
}}
onAudioGenerated={async (sceneId, audioUrl) => {
setGeneratingAudioId(null);
// Use functional update to ensure we're working with latest state
// Ensure scene is marked as approved and has audioUrl
setScript((currentScript) => {
if (!currentScript) return currentScript;
const updatedScenes = currentScript.scenes.map((s) =>
s.id === sceneId ? { ...s, audioUrl, approved: true } : s
);
const updatedScript = { ...currentScript, scenes: updatedScenes };
onScriptChange(updatedScript);
return updatedScript;
});
}}
idea={idea}
/> />
</GlassyCard> </GlassyCard>
))} ))}
@@ -188,39 +582,187 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
<Paper <Paper
sx={{ sx={{
p: 3, p: 3.5,
background: alpha("#1e293b", 0.6), background: allApproved
border: allApproved ? "2px solid rgba(16,185,129,0.4)" : "1px solid rgba(255,255,255,0.1)", ? "linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(5, 150, 105, 0.05) 100%)"
: "#ffffff",
border: allApproved
? "2px solid rgba(16, 185, 129, 0.25)"
: "1px solid rgba(15, 23, 42, 0.08)",
borderRadius: 3,
boxShadow: allApproved
? "0 4px 6px rgba(16, 185, 129, 0.08), 0 8px 24px rgba(16, 185, 129, 0.06)"
: "0 1px 3px rgba(15, 23, 42, 0.06), 0 4px 12px rgba(15, 23, 42, 0.04)",
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}} }}
> >
<Stack direction="row" justifyContent="space-between" alignItems="center"> <Stack direction="row" justifyContent="space-between" alignItems="center">
<Box> <Box>
<Typography variant="subtitle1" sx={{ mb: 0.5, display: "flex", alignItems: "center", gap: 1 }}> <Typography variant="subtitle1" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 1.5, color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
<CheckCircleIcon fontSize="small" color={allApproved ? "success" : "disabled"} /> <CheckCircleIcon fontSize="small" sx={{ color: allApproved ? "#10b981" : "#94a3b8", fontSize: "1.25rem" }} />
Approval Status Approval Status
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" sx={{ color: "#64748b", fontWeight: 400, lineHeight: 1.6 }}>
{approvedCount} of {totalScenes} scenes approved {approvedCount} of {totalScenes} scenes approved
{!allApproved && " Approve all scenes to enable rendering"} {allScenesHaveAudioAndImages && " All scenes ready for video rendering"}
{!allScenesHaveAudioAndImages && allApproved && " • Generate images for all scenes to enable video rendering"}
{!allApproved && " — Approve all scenes first"}
</Typography> </Typography>
{!allApproved && ( {!allScenesHaveAudioAndImages && (
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={(approvedCount / totalScenes) * 100} value={
allScenesHaveAudioAndImages
? 100
: script
? (script.scenes.filter((s) => s.audioUrl && s.imageUrl).length / totalScenes) * 100
: 0
}
sx={{ mt: 1, height: 6, borderRadius: 3 }} sx={{ mt: 1, height: 6, borderRadius: 3 }}
/> />
)} )}
</Box> </Box>
<PrimaryButton <PrimaryButton
onClick={() => script && onProceedToRendering(script)} onClick={() => script && onProceedToRendering(script)}
disabled={!allApproved} disabled={!allScenesHaveAudioAndImages}
startIcon={<PlayArrowIcon />} startIcon={<PlayArrowIcon />}
tooltip={!allApproved ? "Approve all scenes to proceed to rendering" : "Start rendering all approved scenes"} tooltip={
!allScenesHaveAudioAndImages
? "Generate audio and images for all scenes to proceed to video rendering"
: "Proceed to video rendering (all scenes have audio and images)"
}
> >
Proceed to Rendering Proceed to Rendering
</PrimaryButton> </PrimaryButton>
</Stack> </Stack>
</Paper> </Paper>
{/* Download Audio-Only Podcast Section */}
{allScenesHaveAudio && (
<Paper
sx={{
p: 3,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
border: "1px solid rgba(102, 126, 234, 0.15)",
borderRadius: 2,
}}
>
<Stack spacing={3}>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600 }}>
Download Audio-Only Podcast
</Typography>
{!combinedAudioResult ? (
<>
<PrimaryButton
onClick={combineAudio}
disabled={combiningAudio}
loading={combiningAudio}
startIcon={<DownloadIcon />}
tooltip="Combine all scene audio files into a single podcast episode"
sx={{
minWidth: 280,
fontSize: "1rem",
py: 1.5,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"&:hover": {
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
},
}}
>
{combiningAudio ? "Combining Audio..." : "Download Audio-Only Podcast"}
</PrimaryButton>
<Typography variant="caption" sx={{ color: "#64748b", fontStyle: "italic" }}>
This will combine all {scenesWithAudio} scene audio files into one complete podcast episode.
</Typography>
</>
) : (
<Stack spacing={2}>
{/* Success Alert */}
<Alert
severity="success"
sx={{
background: alpha("#10b981", 0.1),
border: "1px solid rgba(16,185,129,0.3)",
"& .MuiAlert-icon": { color: "#10b981" },
}}
>
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}>
Combined audio generated successfully! ({combinedAudioResult.sceneCount} scenes,{" "}
{Math.round(combinedAudioResult.duration)}s)
</Typography>
</Alert>
{/* Combined Audio Preview */}
<InlineAudioPlayer audioUrl={combinedAudioResult.url} title="Complete Podcast Episode" />
{/* Action Buttons */}
<Stack direction="row" spacing={2}>
<SecondaryButton
onClick={async () => {
try {
// Normalize path
let audioPath = combinedAudioResult.url.startsWith('/')
? combinedAudioResult.url
: `/${combinedAudioResult.url}`;
// Ensure it's a podcast audio endpoint
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || combinedAudioResult.filename;
audioPath = `/api/podcast/audio/${filename}`;
}
// Remove query parameters if present
audioPath = audioPath.split('?')[0];
// Fetch as blob using authenticated client
const response = await aiApiClient.get(audioPath, {
responseType: 'blob',
});
// Create blob URL and download
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = combinedAudioResult.filename || `podcast-episode-${projectId.slice(-8)}.mp3`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up blob URL after a delay
setTimeout(() => {
URL.revokeObjectURL(blobUrl);
}, 100);
} catch (error) {
console.error('Failed to download audio:', error);
onError('Failed to download audio file. Please try again.');
}
}}
startIcon={<DownloadIcon />}
tooltip="Download the combined audio file again"
>
Download Again
</SecondaryButton>
<SecondaryButton
onClick={() => {
setCombinedAudioResult(null);
combineAudio();
}}
disabled={combiningAudio}
loading={combiningAudio}
startIcon={<RefreshIcon />}
tooltip="Regenerate combined audio (useful if scenes were updated)"
>
Regenerate
</SecondaryButton>
</Stack>
</Stack>
)}
</Stack>
</Paper>
)}
</Stack> </Stack>
)} )}
</Box> </Box>

View File

@@ -30,6 +30,11 @@ export type Research = {
why: string; why: string;
mappedFactIds: string[]; mappedFactIds: string[];
}[]; }[];
searchQueries?: string[];
searchType?: string;
provider?: string;
cost?: number;
sourceCount?: number;
}; };
export type Line = { export type Line = {
@@ -37,6 +42,7 @@ export type Line = {
speaker: string; speaker: string;
text: string; text: string;
usedFactIds?: string[]; usedFactIds?: string[];
emphasis?: boolean; // Mark lines that need vocal emphasis
}; };
export type Scene = { export type Scene = {
@@ -45,6 +51,9 @@ export type Scene = {
duration: number; duration: number;
lines: Line[]; lines: Line[];
approved?: boolean; approved?: boolean;
emotion?: string; // Scene-specific emotion
audioUrl?: string; // Generated audio URL for this scene
imageUrl?: string; // Generated image URL for this scene (for video generation)
}; };
export type Script = { export type Script = {
@@ -75,6 +84,7 @@ export type Job = {
voiceId?: string | null; voiceId?: string | null;
fileSize?: number | null; fileSize?: number | null;
avatarImageUrl?: string | null; avatarImageUrl?: string | null;
imageUrl?: string | null; // Scene-specific image URL
}; };
export type PodcastAnalysis = { export type PodcastAnalysis = {
@@ -84,6 +94,15 @@ export type PodcastAnalysis = {
suggestedOutlines: { id: number | string; title: string; segments: string[] }[]; suggestedOutlines: { id: number | string; title: string; segments: string[] }[];
suggestedKnobs: Knobs; suggestedKnobs: Knobs;
titleSuggestions: string[]; titleSuggestions: string[];
exaSuggestedConfig?: {
exa_search_type?: "auto" | "keyword" | "neural";
exa_category?: string;
exa_include_domains?: string[];
exa_exclude_domains?: string[];
max_sources?: number;
include_statistics?: boolean;
date_range?: string;
};
}; };
export type PodcastEstimate = { export type PodcastEstimate = {

View File

@@ -5,10 +5,16 @@ import { Paper, alpha } from "@mui/material";
export const GlassyCard = motion(Paper); export const GlassyCard = motion(Paper);
export const glassyCardSx = { export const glassyCardSx = {
borderRadius: 2, borderRadius: 3,
border: "1px solid rgba(0,0,0,0.08)", border: "1px solid rgba(15, 23, 42, 0.06)",
background: "#ffffff", background: "#ffffff",
p: 2.5, p: 3,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)", boxShadow: "0 1px 3px rgba(15, 23, 42, 0.06), 0 4px 12px rgba(15, 23, 42, 0.04)",
color: "#0f172a",
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
"&:hover": {
boxShadow: "0 4px 6px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(15, 23, 42, 0.06)",
borderColor: "rgba(15, 23, 42, 0.1)",
},
}; };

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Button, CircularProgress, Tooltip, alpha } from "@mui/material"; import { Button, CircularProgress, Tooltip, alpha, SxProps, Theme } from "@mui/material";
interface PrimaryButtonProps { interface PrimaryButtonProps {
children: React.ReactNode; children: React.ReactNode;
@@ -9,6 +9,7 @@ interface PrimaryButtonProps {
startIcon?: React.ReactNode; startIcon?: React.ReactNode;
tooltip?: string; tooltip?: string;
ariaLabel?: string; ariaLabel?: string;
sx?: SxProps<Theme>;
} }
export const PrimaryButton: React.FC<PrimaryButtonProps> = ({ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
@@ -19,6 +20,7 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
startIcon, startIcon,
tooltip, tooltip,
ariaLabel, ariaLabel,
sx,
}) => { }) => {
const button = ( const button = (
<Button <Button
@@ -41,6 +43,7 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
background: alpha("#9ca3af", 0.3), background: alpha("#9ca3af", 0.3),
color: alpha("#fff", 0.5), color: alpha("#fff", 0.5),
}, },
...sx,
}} }}
> >
{children} {children}

View File

@@ -1,10 +1,11 @@
import React from "react"; import React from "react";
import { Button, Tooltip, alpha } from "@mui/material"; import { Button, Tooltip, CircularProgress, alpha } from "@mui/material";
interface SecondaryButtonProps { interface SecondaryButtonProps {
children: React.ReactNode; children: React.ReactNode;
onClick?: () => void; onClick?: () => void;
disabled?: boolean; disabled?: boolean;
loading?: boolean;
startIcon?: React.ReactNode; startIcon?: React.ReactNode;
tooltip?: string; tooltip?: string;
ariaLabel?: string; ariaLabel?: string;
@@ -14,6 +15,7 @@ export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
children, children,
onClick, onClick,
disabled = false, disabled = false,
loading = false,
startIcon, startIcon,
tooltip, tooltip,
ariaLabel, ariaLabel,
@@ -22,8 +24,8 @@ export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
<Button <Button
variant="outlined" variant="outlined"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled || loading}
startIcon={startIcon} startIcon={loading ? <CircularProgress size={16} /> : startIcon}
aria-label={ariaLabel} aria-label={ariaLabel}
sx={{ sx={{
borderColor: "rgba(255,255,255,0.2)", borderColor: "rgba(255,255,255,0.2)",

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { Suspense } from 'react';
import { import {
Card, Card,
CardContent, CardContent,
@@ -6,10 +6,17 @@ import {
Box, Box,
Grid, Grid,
Chip, Chip,
CircularProgress,
} from '@mui/material'; } from '@mui/material';
import {
TrendingUp as TrendingUpIcon,
TrendingDown as TrendingDownIcon,
CalendarToday as CalendarIcon,
} from '@mui/icons-material';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
LineChart, LazyLineChart,
LazyAreaChart,
Line, Line,
XAxis, XAxis,
YAxis, YAxis,
@@ -17,13 +24,8 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
Area, Area,
AreaChart ChartLoadingFallback
} from 'recharts'; } from '../../utils/lazyRecharts';
import {
TrendingUp,
TrendingDown,
Calendar
} from 'lucide-react';
// Types // Types
import { UsageTrends as UsageTrendsType, CostProjections } from '../../types/billing'; import { UsageTrends as UsageTrendsType, CostProjections } from '../../types/billing';
@@ -113,7 +115,7 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
{/* Header */} {/* Header */}
<CardContent sx={{ pb: 1 }}> <CardContent sx={{ pb: 1 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold', mb: 2 }}> <Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold', mb: 2 }}>
<TrendingUp size={20} /> <TrendingUpIcon fontSize="small" />
Usage Trends & Projections Usage Trends & Projections
</Typography> </Typography>
</CardContent> </CardContent>
@@ -138,9 +140,9 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
> >
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
{costGrowth >= 0 ? ( {costGrowth >= 0 ? (
<TrendingUp size={16} color="#22c55e" /> <TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
) : ( ) : (
<TrendingDown size={16} color="#ef4444" /> <TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
)} )}
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Cost Growth Cost Growth
@@ -176,9 +178,9 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
> >
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
{callsGrowth >= 0 ? ( {callsGrowth >= 0 ? (
<TrendingUp size={16} color="#22c55e" /> <TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
) : ( ) : (
<TrendingDown size={16} color="#ef4444" /> <TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
)} )}
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Calls Growth Calls Growth
@@ -205,7 +207,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
</Typography> </Typography>
<Box sx={{ height: 200 }}> <Box sx={{ height: 200 }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}> <Suspense fallback={<ChartLoadingFallback />}>
<LazyAreaChart data={chartData}>
<defs> <defs>
<linearGradient id="costGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="costGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#667eea" stopOpacity={0.3}/> <stop offset="5%" stopColor="#667eea" stopOpacity={0.3}/>
@@ -234,7 +237,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
fillOpacity={1} fillOpacity={1}
fill="url(#costGradient)" fill="url(#costGradient)"
/> />
</AreaChart> </LazyAreaChart>
</Suspense>
</ResponsiveContainer> </ResponsiveContainer>
</Box> </Box>
</Box> </Box>
@@ -246,7 +250,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
</Typography> </Typography>
<Box sx={{ height: 150 }}> <Box sx={{ height: 150 }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}> <Suspense fallback={<ChartLoadingFallback />}>
<LazyLineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" /> <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis <XAxis
dataKey="period" dataKey="period"
@@ -267,7 +272,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
dot={{ fill: '#764ba2', strokeWidth: 2, r: 4 }} dot={{ fill: '#764ba2', strokeWidth: 2, r: 4 }}
activeDot={{ r: 6, stroke: '#764ba2', strokeWidth: 2 }} activeDot={{ r: 6, stroke: '#764ba2', strokeWidth: 2 }}
/> />
</LineChart> </LazyLineChart>
</Suspense>
</ResponsiveContainer> </ResponsiveContainer>
</Box> </Box>
</Box> </Box>
@@ -282,7 +288,7 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
}} }}
> >
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
<Calendar size={16} /> <CalendarIcon fontSize="small" />
Monthly Projections Monthly Projections
</Typography> </Typography>

View File

@@ -65,7 +65,7 @@ const DEFAULT_STATE: PodcastProjectState = {
scriptData: null, scriptData: null,
renderJobs: [], renderJobs: [],
knobs: DEFAULT_KNOBS, knobs: DEFAULT_KNOBS,
researchProvider: "google", researchProvider: "exa",
budgetCap: 50, budgetCap: 50,
showScriptEditor: false, showScriptEditor: false,
showRenderQueue: false, showRenderQueue: false,
@@ -327,7 +327,7 @@ export const usePodcastProjectState = () => {
scriptData: dbProject.script_data, scriptData: dbProject.script_data,
renderJobs: dbProject.render_jobs || [], renderJobs: dbProject.render_jobs || [],
knobs: dbProject.knobs || DEFAULT_KNOBS, knobs: dbProject.knobs || DEFAULT_KNOBS,
researchProvider: dbProject.research_provider || 'google', researchProvider: dbProject.research_provider || 'exa',
budgetCap: dbProject.budget_cap || 50, budgetCap: dbProject.budget_cap || 50,
showScriptEditor: dbProject.show_script_editor || false, showScriptEditor: dbProject.show_script_editor || false,
showRenderQueue: dbProject.show_render_queue || false, showRenderQueue: dbProject.show_render_queue || false,

View File

@@ -1,7 +1,6 @@
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse, ResearchProvider } from "./blogWriterApi"; import { ResearchProvider, ResearchConfig } from "./blogWriterApi";
import { import {
storyWriterApi, storyWriterApi,
StoryGenerationRequest,
StoryScene, StoryScene,
StorySetupGenerationResponse, StorySetupGenerationResponse,
} from "./storyWriterApi"; } from "./storyWriterApi";
@@ -22,11 +21,8 @@ import {
Script, Script,
} from "../components/PodcastMaker/types"; } from "../components/PodcastMaker/types";
import { checkPreflight, PreflightOperation } from "./billingService"; import { checkPreflight, PreflightOperation } from "./billingService";
import { TaskStatusResponse } from "./blogWriterApi";
import { TaskStatus } from "./storyWriterApi"; import { TaskStatus } from "./storyWriterApi";
type WaitForTaskFn = (taskId: string) => Promise<TaskStatusResponse>;
const DEFAULT_KNOBS: Knobs = { const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral", voice_emotion: "neutral",
voice_speed: 1, voice_speed: 1,
@@ -36,7 +32,7 @@ const DEFAULT_KNOBS: Knobs = {
bitrate: "standard", bitrate: "standard",
}; };
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const createId = (prefix: string) => { const createId = (prefix: string) => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
@@ -125,33 +121,53 @@ const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string):
return generated.slice(0, 6); return generated.slice(0, 6);
}; };
const mapSourcesToFacts = (sources: BlogResearchResponse["sources"]): Fact[] => { const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => {
if (!sources || !sources.length) return []; if (!sources || !sources.length) return [];
return sources.slice(0, 12).map((source, idx) => ({ return sources.slice(0, 12).map((source: ExaSource, idx: number) => ({
id: source.url || createId("fact"), id: source.url || createId("fact"),
quote: source.excerpt || source.title || "Insight", quote: source.excerpt || source.title || "Insight",
url: source.url || "", url: source.url || "",
date: source.published_at || "Unknown", date: source.published_at || "Unknown",
confidence: typeof source.credibility_score === "number" ? source.credibility_score : 0.8 - idx * 0.02, confidence: typeof (source as any).credibility_score === "number" ? (source as any).credibility_score : Math.max(0.5, 0.85 - idx * 0.02),
})); }));
}; };
const mapResearchResponse = (response: BlogResearchResponse): Research => { type ExaSource = {
title?: string;
url?: string;
excerpt?: string;
published_at?: string;
highlights?: string[];
summary?: string;
source_type?: string;
index?: number;
};
type ExaResearchResult = {
sources: ExaSource[];
search_queries?: string[];
cost?: { total?: number };
search_type?: string;
provider?: string;
content?: string;
};
const mapExaResearchResponse = (response: ExaResearchResult): Research => {
const factCards = mapSourcesToFacts(response.sources); const factCards = mapSourcesToFacts(response.sources);
const summary = const summary =
response.keyword_analysis?.summary || response.content?.slice(0, 1200) ||
response.keyword_analysis?.key_insights?.join(" • ") || (response.search_queries && response.search_queries.length
"Research completed. Review fact cards for details."; ? `Research completed for queries: ${response.search_queries.join(", ")}`
const mappedAngles = : "Research completed. Review fact cards for details.");
response.suggested_angles?.map((angle, idx) => ({
title: angle,
why: response.keyword_analysis?.angle_breakdown?.[angle]?.reason || "High priority topic from research insights.",
mappedFactIds: factCards.slice(idx, idx + 2).map((fact) => fact.id),
})) || [];
return { return {
summary, summary,
factCards, factCards,
mappedAngles, mappedAngles: [],
searchQueries: response.search_queries,
searchType: response.search_type,
provider: response.provider || "exa",
cost: response.cost?.total,
sourceCount: response.sources?.length || 0,
}; };
}; };
@@ -176,71 +192,36 @@ const splitIntoLines = (text: string, speakers: number): Line[] => {
})); }));
}; };
const storySceneToPodcastScene = (scene: StoryScene, knobs: Knobs, speakers: number): Scene => { // Unused helper functions - kept for reference but not currently used
const text = scene.description || scene.audio_narration || scene.image_prompt || scene.title || "Narration"; // const storySceneToPodcastScene = (scene: StoryScene, knobs: Knobs, speakers: number): Scene => {
return { // const text = scene.description || scene.audio_narration || scene.image_prompt || scene.title || "Narration";
id: `scene-${scene.scene_number || createId("scene")}`, // return {
title: scene.title || `Scene ${scene.scene_number}`, // id: `scene-${scene.scene_number || createId("scene")}`,
duration: Math.max(20, knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target), // title: scene.title || `Scene ${scene.scene_number}`,
lines: splitIntoLines(text, Math.max(1, speakers)), // duration: Math.max(20, knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target),
approved: false, // lines: splitIntoLines(text, Math.max(1, speakers)),
}; // approved: false,
}; // };
// };
const ensureScenes = (outline: StorySetupGenerationResponse["options"] | StoryScene[] | string | undefined): StoryScene[] => { // const ensureScenes = (outline: StorySetupGenerationResponse["options"] | StoryScene[] | string | undefined): StoryScene[] => {
if (!outline) return []; // if (!outline) return [];
if (typeof outline === "string") { // if (typeof outline === "string") {
return [ // return [
{ // {
scene_number: 1, // scene_number: 1,
title: outline.slice(0, 60), // title: outline.slice(0, 60),
description: outline, // description: outline,
image_prompt: outline, // image_prompt: outline,
audio_narration: outline, // audio_narration: outline,
} as StoryScene, // } as StoryScene,
]; // ];
} // }
if (Array.isArray(outline)) { // if (Array.isArray(outline)) {
return outline as StoryScene[]; // return outline as StoryScene[];
} // }
return []; // return [];
}; // };
const waitForTaskCompletion = async (
taskId: string,
poll: WaitForTaskFn,
onProgress?: (status: { status: string; progress?: number; message?: string }) => void
): Promise<any> => {
let attempts = 0;
while (attempts < 120) {
const status = await poll(taskId);
// Report progress if callback provided
if (onProgress) {
// Extract latest progress message if available
const latestMessage = status.progress_messages && status.progress_messages.length > 0
? status.progress_messages[status.progress_messages.length - 1].message
: undefined;
onProgress({
status: status.status,
progress: undefined, // TaskStatusResponse doesn't have progress field
message: latestMessage,
});
}
if (status.status === "completed") {
return status.result;
}
if (status.status === "failed") {
const errorMsg = status.error || "Task failed";
throw new Error(errorMsg);
}
await sleep(2500);
attempts += 1;
}
throw new Error("Task polling timed out after 5 minutes");
};
const ensurePreflight = async (operation: PreflightOperation) => { const ensurePreflight = async (operation: PreflightOperation) => {
const result = await checkPreflight(operation); const result = await checkPreflight(operation);
@@ -275,6 +256,7 @@ export const podcastApi = {
suggestedOutlines: outlines, suggestedOutlines: outlines,
suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs }, suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs },
titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean), titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean),
exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined,
}; };
const researchConfig = await getResearchConfig().catch(() => null); const researchConfig = await getResearchConfig().catch(() => null);
@@ -303,62 +285,53 @@ export const podcastApi = {
topic: string; topic: string;
approvedQueries: Query[]; approvedQueries: Query[];
provider?: ResearchProvider; provider?: ResearchProvider;
exaConfig?: ResearchConfig;
onProgress?: (message: string) => void; onProgress?: (message: string) => void;
}): Promise<{ research: Research; raw: BlogResearchResponse }> { }): Promise<{ research: Research; raw: any }> {
const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean); const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean);
if (!keywords.length) { if (!keywords.length) {
throw new Error("At least one query must be approved for research."); throw new Error("At least one query must be approved for research.");
} }
const researchPayload: BlogResearchRequest = { // Ensure Exa payload respects API constraint: when requesting contents, only one of includeDomains or excludeDomains.
keywords, let sanitizedExaConfig: ResearchConfig | undefined = params.exaConfig;
topic: params.topic || keywords[0], if (sanitizedExaConfig && sanitizedExaConfig.exa_include_domains?.length) {
research_mode: "basic", sanitizedExaConfig = {
config: { ...sanitizedExaConfig,
provider: params.provider || "google", exa_exclude_domains: undefined,
include_statistics: params.approvedQueries.some((q) => q.needsRecentStats), };
}, } else if (sanitizedExaConfig && sanitizedExaConfig.exa_exclude_domains?.length) {
}; sanitizedExaConfig = {
...sanitizedExaConfig,
exa_include_domains: undefined,
};
}
await ensurePreflight({ await ensurePreflight({
provider: params.provider === "exa" ? "exa" : "gemini", provider: "exa",
operation_type: params.provider === "exa" ? "exa_neural_search" : "google_grounding", operation_type: "exa_neural_search",
tokens_requested: params.provider === "exa" ? 0 : 1200, tokens_requested: 0,
actual_provider_name: params.provider || "google", actual_provider_name: "exa",
}); });
const { task_id } = await blogWriterApi.startResearch(researchPayload); const response = await aiApiClient.post("/api/podcast/research/exa", {
let lastProgressMessage = ""; topic: params.topic || keywords[0],
const result = (await waitForTaskCompletion( queries: keywords,
task_id, exa_config: sanitizedExaConfig,
blogWriterApi.pollResearchStatus, });
(status) => {
// Extract latest progress message and notify caller const exaResult = response.data as ExaResearchResult;
if (status.message && status.message !== lastProgressMessage) { if (params.onProgress) {
lastProgressMessage = status.message; params.onProgress("Deep research completed with Exa.");
if (params.onProgress) { }
params.onProgress(status.message); const mapped = mapExaResearchResponse(exaResult);
} return { research: mapped, raw: exaResult };
} else if (status.status === "running" && !status.message) {
// Provide default status messages if none available
const defaultMessage = params.provider === "exa"
? "Deep research in progress..."
: "Gathering research sources...";
if (params.onProgress && lastProgressMessage !== defaultMessage) {
lastProgressMessage = defaultMessage;
params.onProgress(defaultMessage);
}
}
}
)) as BlogResearchResponse;
const mapped = mapResearchResponse(result);
return { research: mapped, raw: result };
}, },
async generateScript(params: { async generateScript(params: {
projectId: string; projectId: string;
idea: string; idea: string;
research?: BlogResearchResponse | null; research?: ExaResearchResult | null;
knobs: Knobs; knobs: Knobs;
speakers: number; speakers: number;
durationMinutes: number; durationMinutes: number;
@@ -433,22 +406,96 @@ export const podcastApi = {
}; };
}, },
async renderSceneAudio(params: { scene: Scene; voiceId?: string; emotion?: string; speed?: number }): Promise<RenderJobResult> { async renderSceneAudio(params: {
const text = params.scene.lines.map((line) => line.text).join(" "); scene: Scene;
voiceId?: string;
emotion?: string; // Fallback if scene doesn't have emotion
speed?: number;
}): Promise<RenderJobResult> {
// Use scene-specific emotion if available, otherwise fallback to provided/default
const sceneEmotion = params.scene.emotion || params.emotion || "neutral";
// Optimize text for Minimax Speech-02-HD TTS
// - Strip markdown formatting (bold, italic, etc.) - TTS reads it literally
// - Use pause markers <#x#> for natural speech rhythm
// - Add longer pauses for speaker changes
// - Preserve punctuation for natural breathing
// - Add emphasis pauses for important points
const text = params.scene.lines
.map((line, idx) => {
let lineText = line.text.trim();
// Strip markdown formatting - TTS reads asterisks and other markdown literally
// Remove bold (**text** or __text__)
lineText = lineText.replace(/\*\*([^*]+)\*\*/g, '$1'); // **bold**
lineText = lineText.replace(/\*([^*]+)\*/g, '$1'); // *bold* (single asterisk)
lineText = lineText.replace(/__([^_]+)__/g, '$1'); // __bold__
lineText = lineText.replace(/_([^_]+)_/g, '$1'); // _italic_ (single underscore)
// Remove any remaining stray asterisks or underscores
lineText = lineText.replace(/\*+/g, ''); // Remove any remaining asterisks
lineText = lineText.replace(/_+/g, ''); // Remove any remaining underscores
// Clean up extra spaces
lineText = lineText.replace(/\s+/g, ' ').trim();
// Preserve punctuation (Minimax uses it for natural breathing)
// Don't strip punctuation - it helps TTS understand natural pauses
// Add emphasis pause after lines marked with emphasis
if (line.emphasis) {
// Minimal pause after emphasized content (0.15s for subtle emphasis)
lineText = `${lineText}<#0.15#>`;
}
// Check for speaker change (longer pause for natural conversation flow)
const prevLine = idx > 0 ? params.scene.lines[idx - 1] : null;
const isSpeakerChange = prevLine && prevLine.speaker !== line.speaker;
if (isSpeakerChange) {
// Short pause for speaker changes (0.2s - enough for natural transition)
lineText = `<#0.2#>${lineText}`;
}
// Add minimal pause between lines (only between regular lines, very short)
if (idx < params.scene.lines.length - 1) {
if (!line.emphasis && !isSpeakerChange) {
// Very short pause between lines (0.08s - barely noticeable but helps flow)
lineText = `${lineText}<#0.08#>`;
}
// If emphasis or speaker change, the pause is already added above
}
return lineText;
})
.join(" ");
// Validate character limit (Minimax max: 10,000 characters)
const MAX_CHARS = 10000;
let textToUse = text;
if (text.length > MAX_CHARS) {
console.warn(
`[Podcast] Scene "${params.scene.title}" exceeds ${MAX_CHARS} character limit (${text.length} chars). Truncating...`
);
// Truncate at word boundary to avoid cutting mid-word
const truncated = text.substring(0, MAX_CHARS);
const lastSpace = truncated.lastIndexOf(" ");
textToUse = lastSpace > 0 ? truncated.substring(0, lastSpace) : truncated;
}
await ensurePreflight({ await ensurePreflight({
provider: "audio", provider: "audio",
operation_type: "tts_full_render", operation_type: "tts_full_render",
tokens_requested: text.length, tokens_requested: textToUse.length,
actual_provider_name: "wavespeed", actual_provider_name: "wavespeed",
}); });
const response = await aiApiClient.post("/api/podcast/audio", { const response = await aiApiClient.post("/api/podcast/audio", {
scene_id: params.scene.id, scene_id: params.scene.id,
scene_title: params.scene.title, scene_title: params.scene.title,
text, text: textToUse,
voice_id: params.voiceId || "Wise_Woman", voice_id: params.voiceId || "Wise_Woman",
speed: params.speed || 1.0, speed: params.speed || 1.0, // Normal speed (was 0.9, but too slow - causing duration issues)
emotion: params.emotion || "neutral", emotion: sceneEmotion,
english_normalization: true, // Better number reading for statistics
}); });
return { return {
@@ -578,6 +625,35 @@ export const podcastApi = {
return response.data; return response.data;
}, },
async generateSceneImage(params: {
sceneId: string;
sceneTitle: string;
sceneContent?: string;
idea?: string;
width?: number;
height?: number;
}): Promise<{
scene_id: string;
scene_title: string;
image_filename: string;
image_url: string;
width: number;
height: number;
provider: string;
model?: string;
cost: number;
}> {
const response = await aiApiClient.post("/api/podcast/image", {
scene_id: params.sceneId,
scene_title: params.sceneTitle,
scene_content: params.sceneContent,
idea: params.idea,
width: params.width || 1024,
height: params.height || 1024,
});
return response.data;
},
async cancelTask(taskId: string): Promise<void> { async cancelTask(taskId: string): Promise<void> {
// Note: Task cancellation may not be fully supported by backend yet // Note: Task cancellation may not be fully supported by backend yet
// This is a placeholder for future implementation // This is a placeholder for future implementation
@@ -587,6 +663,25 @@ export const podcastApi = {
console.warn("Task cancellation not supported:", error); console.warn("Task cancellation not supported:", error);
} }
}, },
async combineAudio(params: {
projectId: string;
sceneIds: string[];
sceneAudioUrls: string[];
}): Promise<{
combined_audio_url: string;
combined_audio_filename: string;
total_duration: number;
file_size: number;
scene_count: number;
}> {
const response = await aiApiClient.post("/api/podcast/combine-audio", {
project_id: params.projectId,
scene_ids: params.sceneIds,
scene_audio_urls: params.sceneAudioUrls,
});
return response.data;
},
}; };
export type PodcastApi = typeof podcastApi; export type PodcastApi = typeof podcastApi;

View File

@@ -1,5 +1,6 @@
/* Global Styles for Alwrity Onboarding */ /* Global Styles for Alwrity Onboarding */
/* Optimized font loading with font-display: swap for better performance */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
/* Smooth scrolling */ /* Smooth scrolling */

View File

@@ -0,0 +1,77 @@
/**
* Lazy-loaded Recharts wrapper
*
* Recharts is a large library (~200+ KiB). This wrapper lazy-loads it
* only when charts are actually needed, reducing initial bundle size.
*
* Usage:
* import { LazyLineChart, Line, XAxis, YAxis } from '../../utils/lazyRecharts';
* import { Suspense } from 'react';
*
* <Suspense fallback={<ChartSkeleton />}>
* <LazyLineChart data={data}>
* <Line />
* </LazyLineChart>
* </Suspense>
*/
import React, { Suspense, lazy } from 'react';
import { Box, CircularProgress } from '@mui/material';
// Loading fallback for charts
export const ChartLoadingFallback: React.FC = () => (
<Box
display="flex"
alignItems="center"
justifyContent="center"
minHeight="200px"
sx={{ bgcolor: 'rgba(0,0,0,0.02)', borderRadius: 1 }}
>
<CircularProgress size={40} />
</Box>
);
// Lazy load recharts components - these are the heavy ones
export const LazyLineChart = lazy(() =>
import('recharts').then(module => ({ default: module.LineChart }))
);
export const LazyBarChart = lazy(() =>
import('recharts').then(module => ({ default: module.BarChart }))
);
export const LazyPieChart = lazy(() =>
import('recharts').then(module => ({ default: module.PieChart }))
);
export const LazyAreaChart = lazy(() =>
import('recharts').then(module => ({ default: module.AreaChart }))
);
export const LazyRadarChart = lazy(() =>
import('recharts').then(module => ({ default: module.RadarChart }))
);
export const LazyComposedChart = lazy(() =>
import('recharts').then(module => ({ default: module.ComposedChart }))
);
// These are lightweight, can be imported directly
export {
Line,
Bar,
Pie,
Area,
Radar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis
} from 'recharts';

View File

@@ -0,0 +1,10 @@
/**
* Lazy-loaded Wix SDK wrapper
*
* Wix SDK is only used in a few pages (WixTestPage, WixCallbackPage).
* This wrapper lazy-loads it only when needed.
*/
export const lazyWixSDK = () => import('@wix/sdk');
export const lazyWixBlog = () => import('@wix/blog');

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB