AI podcast maker performance optimizations
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -9,6 +9,12 @@ __pycache__/
|
||||
backend/.onboarding_progress.json
|
||||
backend/database/migrations/*
|
||||
|
||||
*.mp3
|
||||
podcast_audio/*
|
||||
backend/podcast_audio/
|
||||
|
||||
|
||||
|
||||
.cursorignore
|
||||
story_videos
|
||||
story_videos/*
|
||||
|
||||
@@ -1,19 +1,67 @@
|
||||
"""
|
||||
Frontend Serving Module
|
||||
Handles React frontend serving and static file mounting.
|
||||
Handles React frontend serving and static file mounting with cache headers.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
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 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:
|
||||
"""Manages React frontend serving and static file mounting."""
|
||||
"""Manages React frontend serving and static file mounting with cache headers."""
|
||||
|
||||
def __init__(self, app: FastAPI):
|
||||
self.app = app
|
||||
@@ -21,14 +69,26 @@ class FrontendServing:
|
||||
self.static_path = os.path.join(self.frontend_build_path, "static")
|
||||
|
||||
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:
|
||||
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)
|
||||
if os.path.exists(self.static_path):
|
||||
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
|
||||
else:
|
||||
logger.info("Frontend build directory not found. Static files not mounted.")
|
||||
@@ -39,13 +99,23 @@ class FrontendServing:
|
||||
return False
|
||||
|
||||
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:
|
||||
# Check if frontend build exists
|
||||
index_html = os.path.join(self.frontend_build_path, "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:
|
||||
return {
|
||||
"message": "Frontend not built. Please run 'npm run build' in the frontend directory.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -300,6 +300,7 @@ class StoryAudioGenerationService:
|
||||
volume: float = 1.0,
|
||||
pitch: float = 0.0,
|
||||
emotion: str = "happy",
|
||||
english_normalization: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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).
|
||||
pitch (float): Speech pitch (-12 to 12, default: 0.0).
|
||||
emotion (str): Emotion for speech (default: "happy").
|
||||
english_normalization (bool): Enable English text normalization for better number reading (default: False).
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Audio metadata including file path, URL, and scene info.
|
||||
@@ -337,6 +339,7 @@ class StoryAudioGenerationService:
|
||||
pitch=pitch,
|
||||
emotion=emotion,
|
||||
user_id=user_id,
|
||||
english_normalization=english_normalization,
|
||||
)
|
||||
|
||||
# Save audio to file
|
||||
|
||||
197
frontend/BUILD_OPTIMIZATION.md
Normal file
197
frontend/BUILD_OPTIMIZATION.md
Normal 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
|
||||
|
||||
114
frontend/OPTIMIZATION_PROGRESS.md
Normal file
114
frontend/OPTIMIZATION_PROGRESS.md
Normal 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
|
||||
|
||||
162
frontend/PERFORMANCE_OPTIMIZATIONS.md
Normal file
162
frontend/PERFORMANCE_OPTIMIZATIONS.md
Normal 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
|
||||
|
||||
231
frontend/UNUSED_JAVASCRIPT_OPTIMIZATION.md
Normal file
231
frontend/UNUSED_JAVASCRIPT_OPTIMIZATION.md
Normal 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
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"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": {
|
||||
"extends": [
|
||||
@@ -56,7 +58,8 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^4.9.5"
|
||||
"typescript": "^4.9.5",
|
||||
"source-map-explorer": "^2.5.2"
|
||||
},
|
||||
"proxy": "http://localhost:8000",
|
||||
"homepage": "/"
|
||||
|
||||
@@ -9,6 +9,18 @@
|
||||
name="description"
|
||||
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="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>Alwrity - AI Content Creation Platform</title>
|
||||
|
||||
@@ -508,6 +508,7 @@ const ContentLifecyclePillars: React.FC = () => {
|
||||
borderRadius: 2,
|
||||
mb: 4,
|
||||
position: 'relative', // For hero section positioning
|
||||
minHeight: '200px', // Reserve space for hero section to prevent layout shift
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="xl">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -84,15 +84,17 @@ const MainDashboard: React.FC = () => {
|
||||
initializeWorkflow();
|
||||
}, [generateDailyWorkflow]);
|
||||
|
||||
// Debug logging for workflow state
|
||||
// Debug logging for workflow state (only in development)
|
||||
React.useEffect(() => {
|
||||
console.log('Workflow Debug:', {
|
||||
currentWorkflow,
|
||||
workflowProgress,
|
||||
isWorkflowActive: currentWorkflow?.workflowStatus === 'in_progress',
|
||||
workflowStatus: currentWorkflow?.workflowStatus,
|
||||
hasWorkflow: !!currentWorkflow
|
||||
});
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Workflow Debug:', {
|
||||
currentWorkflow,
|
||||
workflowProgress,
|
||||
isWorkflowActive: currentWorkflow?.workflowStatus === 'in_progress',
|
||||
workflowStatus: currentWorkflow?.workflowStatus,
|
||||
hasWorkflow: !!currentWorkflow
|
||||
});
|
||||
}
|
||||
}, [currentWorkflow, workflowProgress]);
|
||||
|
||||
// State to track if we need to start a newly generated workflow
|
||||
@@ -166,42 +168,50 @@ const MainDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToolClick = (tool: Tool) => {
|
||||
console.log('Navigating to tool:', tool.path);
|
||||
const handleToolClick = useCallback((tool: Tool) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Navigating to tool:', tool.path);
|
||||
}
|
||||
if (tool.path) {
|
||||
navigate(tool.path);
|
||||
return;
|
||||
}
|
||||
showSnackbar(`Launching ${tool.name}...`, 'info');
|
||||
};
|
||||
}, [navigate, showSnackbar]);
|
||||
|
||||
// Handle category click to open modal
|
||||
const handleCategoryClick = (categoryName: string | null, categoryData?: any) => {
|
||||
const handleCategoryClick = useCallback((categoryName: string | null, categoryData?: any) => {
|
||||
setModalCategoryName(categoryName);
|
||||
setModalCategory(categoryData);
|
||||
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
|
||||
React.useEffect(() => {
|
||||
if (searchQuery && searchQuery.length >= 2) { // Only search after 2+ characters
|
||||
if (searchQuery && searchQuery.length >= 2) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
// 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 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);
|
||||
setSearchResults(searchResultsMemo);
|
||||
setModalCategoryName(null);
|
||||
setModalCategory(null);
|
||||
setToolsModalOpen(true);
|
||||
@@ -212,10 +222,10 @@ const MainDashboard: React.FC = () => {
|
||||
// Close modal if search query is too short
|
||||
setToolsModalOpen(false);
|
||||
}
|
||||
}, [searchQuery]);
|
||||
}, [searchQuery, searchResultsMemo]);
|
||||
|
||||
// Close modal and clear search
|
||||
const handleCloseModal = () => {
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setToolsModalOpen(false);
|
||||
setModalCategoryName(null);
|
||||
setModalCategory(null);
|
||||
@@ -223,7 +233,7 @@ const MainDashboard: React.FC = () => {
|
||||
if (searchQuery) {
|
||||
setSearchQuery('');
|
||||
}
|
||||
};
|
||||
}, [searchQuery, setSearchQuery]);
|
||||
|
||||
// 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%)',
|
||||
padding: theme.spacing(4),
|
||||
position: 'relative',
|
||||
overflow: 'hidden', // Prevent layout shifts from pseudo-elements
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
position: 'fixed', // Changed from absolute to fixed to prevent layout shifts
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 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")',
|
||||
pointerEvents: 'none',
|
||||
willChange: 'transform', // Optimize for animations
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
position: 'fixed', // Changed from absolute to fixed to prevent layout shifts
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '600px',
|
||||
@@ -263,6 +275,7 @@ const MainDashboard: React.FC = () => {
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
willChange: 'transform', // Optimize for animations
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -55,6 +55,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
|
||||
justifyContent: 'center',
|
||||
borderRadius: 2, // Match the parent container's border radius
|
||||
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 */}
|
||||
@@ -130,6 +132,7 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
style={{ width: '100%' }} // Prevent width changes
|
||||
>
|
||||
{/* Main Heading with Rocket */}
|
||||
<Box sx={{
|
||||
@@ -137,7 +140,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
mb: 2
|
||||
mb: 2,
|
||||
minHeight: '48px', // Reserve space for heading to prevent layout shift
|
||||
}}>
|
||||
<Typography
|
||||
variant={isMobile ? "h5" : "h4"}
|
||||
@@ -149,6 +153,7 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
lineHeight: 1.2, // Fixed line height to prevent shift
|
||||
}}
|
||||
>
|
||||
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)',
|
||||
animation: 'shimmer 2.5s infinite',
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none', // Prevent layout impact
|
||||
willChange: 'left', // Optimize animation
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
@@ -233,6 +240,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
|
||||
borderRadius: 'inherit',
|
||||
zIndex: -1,
|
||||
animation: 'borderGlow 3s ease-in-out infinite',
|
||||
pointerEvents: 'none', // Prevent layout impact
|
||||
willChange: 'background-position', // Optimize animation
|
||||
},
|
||||
'@keyframes shimmer': {
|
||||
'0%': { left: '-100%' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
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 { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
|
||||
import { Refresh as RefreshIcon } from "@mui/icons-material";
|
||||
@@ -92,6 +92,82 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, onRegene
|
||||
</Stack>
|
||||
|
||||
<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>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Suggested Episode Outlines</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Box, Typography, TextField, Divider, Button, Alert, alpha, Tooltip, Paper, Chip } from "@mui/material";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Stack, Box, Typography, TextField, Divider, Button, Alert, alpha, Tooltip, Paper, Chip, IconButton } from "@mui/material";
|
||||
import {
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Info as InfoIcon,
|
||||
HelpOutline as HelpOutlineIcon,
|
||||
AttachMoney as AttachMoneyIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { CreateProjectPayload, Knobs } from "./types";
|
||||
import { PrimaryButton, SecondaryButton } from "./ui";
|
||||
@@ -16,17 +18,25 @@ interface CreateModalProps {
|
||||
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 }) => {
|
||||
const { subscription } = useSubscription();
|
||||
const [idea, setIdea] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [showAIDetailsButton, setShowAIDetailsButton] = useState(false);
|
||||
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 [voiceFile, setVoiceFile] = useState<File | null>(null);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
|
||||
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
||||
|
||||
// Determine subscription tier restrictions
|
||||
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 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
|
||||
useEffect(() => {
|
||||
if (!canUseHD && knobs.bitrate === 'hd') {
|
||||
@@ -49,11 +69,42 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
setShowAIDetailsButton(idea.trim().length > 0);
|
||||
}, [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 submit = () => {
|
||||
@@ -72,11 +123,22 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
setIdea("");
|
||||
setUrl("");
|
||||
setSpeakers(1);
|
||||
setDuration(10);
|
||||
setDuration(1);
|
||||
setBudgetCap(50);
|
||||
setVoiceFile(null);
|
||||
setAvatarFile(null);
|
||||
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 (
|
||||
@@ -84,49 +146,227 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
border: "1px solid rgba(15, 23, 42, 0.08)",
|
||||
background: "#ffffff",
|
||||
boxShadow: "0 6px 20px rgba(15, 23, 42, 0.08)",
|
||||
p: { xs: 3, md: 4 },
|
||||
boxShadow: "0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.08)",
|
||||
p: { xs: 3, md: 4.5 },
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between" flexWrap="wrap">
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<AutoAwesomeIcon sx={{ color: "#667eea" }} />
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ color: "#0f172a", fontWeight: 800 }}>
|
||||
Create New Podcast Episode
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Provide either a topic idea or a blog post URL. We start AI analysis only after you click “Analyze & Continue”.
|
||||
</Typography>
|
||||
<Stack spacing={3.5}>
|
||||
{/* Header Section */}
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={2}>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start" sx={{ flex: 1, minWidth: { xs: "100%", md: "60%" } }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
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>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Chip label={`Plan: ${subscription?.tier || "free"}`} size="small" color="default" />
|
||||
<Chip label={`Duration: ${duration} min`} size="small" color="default" />
|
||||
<Chip label={`${speakers} speaker${speakers > 1 ? "s" : ""}`} size="small" color="default" />
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ alignItems: "center" }}>
|
||||
<Tooltip
|
||||
title={`Your current subscription plan: ${subscription?.tier || "free"}. Upgrade for more features.`}
|
||||
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>
|
||||
|
||||
<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 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>
|
||||
</Alert>
|
||||
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="stretch">
|
||||
{/* Topic Idea Section */}
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
Topic Idea
|
||||
</Typography>
|
||||
{/* Input Section */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
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
|
||||
title="Enter a concise idea. We will expand it into an outline only after you click Analyze."
|
||||
arrow
|
||||
@@ -136,7 +376,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
fullWidth
|
||||
multiline
|
||||
rows={5}
|
||||
placeholder="e.g., 'How AI is transforming content marketing in 2024'"
|
||||
placeholder={!idea && !url ? `e.g., "${TOPIC_PLACEHOLDERS[placeholderIndex]}"` : ""}
|
||||
inputProps={{
|
||||
sx: {
|
||||
"&::placeholder": { color: "#94a3b8", opacity: 1 },
|
||||
@@ -152,25 +392,38 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
}
|
||||
}}
|
||||
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={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1.5px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
backgroundColor: "#f1f5f9",
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 2,
|
||||
},
|
||||
"& .MuiOutlinedInput-input": {
|
||||
fontSize: "0.95rem",
|
||||
lineHeight: 1.5,
|
||||
fontSize: "0.9375rem",
|
||||
lineHeight: 1.6,
|
||||
color: "#0f172a",
|
||||
fontWeight: 400,
|
||||
},
|
||||
},
|
||||
"& .MuiInputBase-input::placeholder": {
|
||||
color: "#94a3b8",
|
||||
opacity: 1,
|
||||
fontWeight: 400,
|
||||
},
|
||||
"& .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={{
|
||||
textTransform: "none",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 600,
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 1.5,
|
||||
color: "#667eea",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
borderColor: "#5568d3",
|
||||
backgroundColor: alpha("#667eea", 0.08),
|
||||
@@ -203,20 +459,67 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Center OR divider */}
|
||||
<Stack alignItems="center" justifyContent="center" sx={{ px: { xs: 0, md: 1 } }}>
|
||||
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", md: "block" }, borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
<Divider sx={{ display: { xs: "block", md: "none" }, borderColor: "rgba(0,0,0,0.08)", my: 1 }} />
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 700, fontSize: "0.75rem" }}>
|
||||
OR
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* Center OR divider */}
|
||||
<Stack alignItems="center" justifyContent="center" sx={{ px: { xs: 0, md: 2 } }}>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{
|
||||
display: { xs: "none", md: "block" },
|
||||
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 */}
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
Blog Post URL
|
||||
</Typography>
|
||||
{/* Blog URL 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" }}>
|
||||
Blog Post URL
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Tooltip
|
||||
title="Paste a single article URL. We’ll fetch insights only after you click Analyze."
|
||||
arrow
|
||||
@@ -252,63 +555,246 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1.5px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
"&: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": {
|
||||
color: "#94a3b8",
|
||||
opacity: 1,
|
||||
fontWeight: 400,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: "#475569",
|
||||
color: "#64748b",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 400,
|
||||
mt: 0.75,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Quick settings for duration and speakers */}
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label="Duration (minutes)"
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(Math.max(1, Number(e.target.value) || 0))}
|
||||
InputProps={{ inputProps: { min: 1, max: 60 } }}
|
||||
size="small"
|
||||
helperText="Typical podcasts: 5-20 minutes"
|
||||
sx={{
|
||||
maxWidth: 220,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
"&:hover": { backgroundColor: "#f1f5f9" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Number of speakers"
|
||||
type="number"
|
||||
value={speakers}
|
||||
onChange={(e) => setSpeakers(Math.min(4, Math.max(1, Number(e.target.value) || 1)))}
|
||||
InputProps={{ inputProps: { min: 1, max: 4 } }}
|
||||
size="small"
|
||||
helperText="Supports single or panel style"
|
||||
sx={{
|
||||
maxWidth: 220,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
"&:hover": { backgroundColor: "#f1f5f9" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
{/* Settings Section */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
background: alpha("#f8fafc", 0.5),
|
||||
border: "1px solid rgba(15, 23, 42, 0.06)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
|
||||
Podcast Settings
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="flex-start">
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
label="Duration (minutes)"
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => handleDurationChange(Number(e.target.value) || 1)}
|
||||
InputProps={{ inputProps: { min: 1, max: 10 } }}
|
||||
size="small"
|
||||
helperText={duration > 10 ? "Maximum duration is 10 minutes" : `Recommended: 1-3 minutes for quick tests (currently: ${duration} min)`}
|
||||
error={duration > 10}
|
||||
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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<Alert severity="info" sx={{ background: "#ecfeff", border: "1px solid #bae6fd", borderRadius: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontSize: "0.9rem", color: "#0ea5e9" }}>
|
||||
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”.
|
||||
{/* 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>
|
||||
|
||||
{/* Info Banner */}
|
||||
<Alert
|
||||
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>
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { aiApiClient } from "../../api/client";
|
||||
|
||||
interface InlineAudioPlayerProps {
|
||||
audioUrl: string;
|
||||
@@ -11,37 +12,140 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = 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);
|
||||
|
||||
// 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(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (!audio || !blobUrl) return;
|
||||
|
||||
const updateTime = () => setCurrentTime(audio.currentTime);
|
||||
const updateDuration = () => setDuration(audio.duration);
|
||||
const handleEnd = () => setPlaying(false);
|
||||
const handleError = () => {
|
||||
setError('Audio playback error. Please try again.');
|
||||
setPlaying(false);
|
||||
};
|
||||
|
||||
audio.addEventListener("timeupdate", updateTime);
|
||||
audio.addEventListener("loadedmetadata", updateDuration);
|
||||
audio.addEventListener("ended", handleEnd);
|
||||
audio.addEventListener("error", handleError);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", updateTime);
|
||||
audio.removeEventListener("loadedmetadata", updateDuration);
|
||||
audio.removeEventListener("ended", handleEnd);
|
||||
audio.removeEventListener("error", handleError);
|
||||
};
|
||||
}, [audioUrl]);
|
||||
}, [blobUrl]);
|
||||
|
||||
const togglePlay = () => {
|
||||
const togglePlay = async () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (playing) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play();
|
||||
if (!audio || !blobUrl) {
|
||||
setError('Audio not loaded. Please wait...');
|
||||
return;
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -58,6 +162,8 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
setCurrentTime(newTime);
|
||||
};
|
||||
|
||||
const effectiveAudioUrl = blobUrl || audioUrl;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
@@ -74,8 +180,26 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
{title}
|
||||
</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">
|
||||
<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" />}
|
||||
</IconButton>
|
||||
<Stack flex={1}>
|
||||
@@ -85,7 +209,8 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
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 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@@ -99,18 +224,22 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
<Tooltip title="Download audio">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (!effectiveAudioUrl) return;
|
||||
const link = document.createElement("a");
|
||||
link.href = audioUrl;
|
||||
link.href = effectiveAudioUrl;
|
||||
link.download = title || "podcast-audio.mp3";
|
||||
link.click();
|
||||
}}
|
||||
disabled={!effectiveAudioUrl}
|
||||
sx={{ color: "rgba(255,255,255,0.7)" }}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
||||
{effectiveAudioUrl && (
|
||||
<audio ref={audioRef} src={effectiveAudioUrl} preload="metadata" />
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -1,79 +1,26 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
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 React, { useState, useCallback } from "react";
|
||||
import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha } from "@mui/material";
|
||||
import { usePodcastProjectState } from "../../hooks/usePodcastProjectState";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateProjectPayload, Job, Knobs, Query, Research, Script } from "./types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
|
||||
import { Script } from "./types";
|
||||
import { CreateModal } from "./CreateModal";
|
||||
import { AnalysisPanel } from "./AnalysisPanel";
|
||||
import { FactCard } from "./FactCard";
|
||||
import { ScriptEditor } from "./ScriptEditor";
|
||||
import { RenderQueue } from "./RenderQueue";
|
||||
import { RecentEpisodesPreview } from "./RecentEpisodesPreview";
|
||||
import { ProjectList } from "./ProjectList";
|
||||
import { usePreflightCheck } from "../../hooks/usePreflightCheck";
|
||||
import { useBudgetTracking } from "../../hooks/useBudgetTracking";
|
||||
import { PreflightBlockDialog } from "./PreflightBlockDialog";
|
||||
import HeaderControls from "../shared/HeaderControls";
|
||||
|
||||
/* ================= Helpers ================= */
|
||||
|
||||
const DEFAULT_KNOBS: Knobs = {
|
||||
voice_emotion: "neutral",
|
||||
voice_speed: 1,
|
||||
resolution: "720p",
|
||||
scene_length_target: 45,
|
||||
sample_rate: 24000,
|
||||
bitrate: "standard",
|
||||
};
|
||||
|
||||
const announceError = (setAnnouncement: (msg: string) => void, error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "Unexpected error";
|
||||
setAnnouncement(message);
|
||||
};
|
||||
|
||||
/* ================= Dashboard ================= */
|
||||
import {
|
||||
Header,
|
||||
ProgressStepper,
|
||||
EstimateCard,
|
||||
QuerySelection,
|
||||
ResearchSummary,
|
||||
usePodcastWorkflow,
|
||||
DEFAULT_KNOBS,
|
||||
getStepLabel,
|
||||
} from "./PodcastDashboard/index";
|
||||
|
||||
const PodcastDashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const projectState = usePodcastProjectState();
|
||||
const [showProjectList, setShowProjectList] = useState(false);
|
||||
const {
|
||||
@@ -91,250 +38,39 @@ const PodcastDashboard: React.FC = () => {
|
||||
showScriptEditor,
|
||||
showRenderQueue,
|
||||
currentStep,
|
||||
setProject,
|
||||
setAnalysis,
|
||||
setQueries,
|
||||
setSelectedQueries,
|
||||
setResearch,
|
||||
setRawResearch,
|
||||
setEstimate,
|
||||
setScriptData,
|
||||
updateRenderJob,
|
||||
setKnobs,
|
||||
setResearchProvider,
|
||||
setBudgetCap,
|
||||
setShowScriptEditor,
|
||||
setShowRenderQueue,
|
||||
initializeProject,
|
||||
setResearchProvider,
|
||||
updateRenderJob,
|
||||
resetState,
|
||||
loadProjectFromDb,
|
||||
setCurrentStep,
|
||||
} = 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>("");
|
||||
|
||||
// Budget tracking
|
||||
const budgetTracking = useBudgetTracking(projectState.budgetCap || 50);
|
||||
|
||||
// Preflight check hook
|
||||
const preflightCheck = usePreflightCheck({
|
||||
onBlocked: (response) => {
|
||||
setPreflightResponse(response);
|
||||
setShowPreflightDialog(true);
|
||||
const workflow = usePodcastWorkflow({
|
||||
projectState,
|
||||
onError: (msg: string) => {
|
||||
// Error handling is done through workflow's own announcement system
|
||||
console.error("Workflow error:", msg);
|
||||
},
|
||||
});
|
||||
|
||||
// Update budget cap when project state changes
|
||||
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) => {
|
||||
const handleSelectProject = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
await loadProjectFromDb(projectId);
|
||||
setShowProjectList(false);
|
||||
} 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) {
|
||||
return <ProjectList onSelectProject={handleSelectProject} />;
|
||||
@@ -362,147 +98,93 @@ const PodcastDashboard: React.FC = () => {
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{/* Header */}
|
||||
<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={() => 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>
|
||||
<Header onShowProjects={() => setShowProjectList(true)} onNewEpisode={handleNewEpisode} />
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
|
||||
{/* Progress Stepper */}
|
||||
{project && activeStep >= 0 && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.5,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 2,
|
||||
{project && workflow.activeStep >= 0 && (
|
||||
<ProgressStepper
|
||||
activeStep={workflow.activeStep}
|
||||
completedSteps={[
|
||||
...(analysis ? [0] : []), // Analysis step
|
||||
...(research ? [1] : []), // Research step
|
||||
...(scriptData ? [2] : []), // Script step
|
||||
...(scriptData && renderJobs.length > 0 ? [3] : []), // Render step (if script exists and has jobs)
|
||||
]}
|
||||
onStepClick={(stepIndex) => {
|
||||
// Navigate to the clicked step
|
||||
// Step indices: 0 = Analysis, 1 = Research, 2 = Script, 3 = Render
|
||||
if (stepIndex === 0) {
|
||||
// Navigate to Analysis
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
setCurrentStep('analysis');
|
||||
} else if (stepIndex === 1) {
|
||||
// Navigate to Research
|
||||
if (!analysis) {
|
||||
workflow.setAnnouncement("Complete Analysis first to access Research.");
|
||||
return;
|
||||
}
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
setCurrentStep('research');
|
||||
} else if (stepIndex === 2) {
|
||||
// Navigate to Script
|
||||
if (!research) {
|
||||
workflow.setAnnouncement("Complete Research first to access Script Editor.");
|
||||
return;
|
||||
}
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
setCurrentStep('script');
|
||||
} else if (stepIndex === 3) {
|
||||
// Navigate to Render
|
||||
if (!scriptData) {
|
||||
workflow.setAnnouncement("Generate and approve script first to access Render Queue.");
|
||||
return;
|
||||
}
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(true);
|
||||
setCurrentStep('render');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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 */}
|
||||
{showResumeAlert && project && (
|
||||
{workflow.showResumeAlert && project && (
|
||||
<Alert
|
||||
severity="success"
|
||||
onClose={() => setShowResumeAlert(false)}
|
||||
onClose={() => workflow.setShowResumeAlert(false)}
|
||||
sx={{
|
||||
background: "#d1fae5",
|
||||
border: "1px solid #a7f3d0",
|
||||
"& .MuiAlert-icon": { color: "#10b981" },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
<strong>Project Restored:</strong> Resuming from{" "}
|
||||
{currentStep === "analysis"
|
||||
? "Analysis"
|
||||
: currentStep === "research"
|
||||
? "Research"
|
||||
: currentStep === "script"
|
||||
? "Script Editing"
|
||||
: "Rendering"}{" "}
|
||||
step. Your progress has been saved.
|
||||
</Typography>
|
||||
<Box component="span" sx={{ fontSize: "0.875rem" }}>
|
||||
<strong>Project Restored:</strong> Resuming from {getStepLabel(currentStep)} step. Your progress has been saved.
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Announcements */}
|
||||
{announcement && (
|
||||
{workflow.announcement && (
|
||||
<Alert
|
||||
severity="info"
|
||||
onClose={() => setAnnouncement("")}
|
||||
onClose={() => workflow.setAnnouncement("")}
|
||||
sx={{
|
||||
background: "#dbeafe",
|
||||
border: "1px solid #bfdbfe",
|
||||
"& .MuiAlert-icon": { color: "#3b82f6" },
|
||||
}}
|
||||
>
|
||||
{announcement}
|
||||
{workflow.announcement}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(isAnalyzing || isResearching) && (
|
||||
{(workflow.isAnalyzing || workflow.isResearching) && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
icon={<CircularProgress size={20} />}
|
||||
@@ -511,17 +193,21 @@ const PodcastDashboard: React.FC = () => {
|
||||
border: "1px solid #fde68a",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{isAnalyzing ? "Analyzing your idea with AI..." : "Running research... This may take a moment."}
|
||||
</Typography>
|
||||
<Box component="span" sx={{ fontSize: "0.875rem" }}>
|
||||
{workflow.isAnalyzing ? "Analyzing your idea with AI..." : "Running research... This may take a moment."}
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{!project && (
|
||||
<>
|
||||
<CreateModal open onCreate={handleCreate} defaultKnobs={DEFAULT_KNOBS} isSubmitting={isAnalyzing} />
|
||||
{/* Recent Episodes Preview */}
|
||||
<CreateModal
|
||||
open
|
||||
onCreate={workflow.handleCreate}
|
||||
defaultKnobs={DEFAULT_KNOBS}
|
||||
isSubmitting={workflow.isAnalyzing}
|
||||
/>
|
||||
<RecentEpisodesPreview onSelectEpisode={() => {}} />
|
||||
</>
|
||||
)}
|
||||
@@ -531,217 +217,32 @@ const PodcastDashboard: React.FC = () => {
|
||||
{analysis && !showScriptEditor && !showRenderQueue && (
|
||||
<AnalysisPanel
|
||||
analysis={analysis}
|
||||
onRegenerate={() => setAnalysis({ ...analysis })}
|
||||
onRegenerate={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{estimate && !showScriptEditor && !showRenderQueue && (
|
||||
<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>
|
||||
<EstimateCard estimate={estimate} />
|
||||
)}
|
||||
|
||||
{queries.length > 0 && !showScriptEditor && !showRenderQueue && (
|
||||
<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) => 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>
|
||||
<QuerySelection
|
||||
queries={queries}
|
||||
selectedQueries={selectedQueries}
|
||||
researchProvider={researchProvider}
|
||||
isResearching={workflow.isResearching}
|
||||
onToggleQuery={workflow.toggleQuery}
|
||||
onProviderChange={setResearchProvider}
|
||||
onRunResearch={workflow.handleRunResearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{research && !showScriptEditor && !showRenderQueue && (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
|
||||
<Box>
|
||||
<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>
|
||||
<ResearchSummary
|
||||
research={research}
|
||||
canGenerateScript={workflow.canGenerateScript}
|
||||
onGenerateScript={workflow.handleGenerateScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showScriptEditor && project && research && rawResearch && (
|
||||
@@ -756,8 +257,8 @@ const PodcastDashboard: React.FC = () => {
|
||||
script={scriptData}
|
||||
onScriptChange={(s) => setScriptData(s)}
|
||||
onBackToResearch={() => setShowScriptEditor(false)}
|
||||
onProceedToRendering={(s) => handleProceedToRendering(s)}
|
||||
onError={(msg) => setAnnouncement(msg)}
|
||||
onProceedToRendering={(s) => workflow.handleProceedToRendering(s)}
|
||||
onError={(msg) => workflow.setAnnouncement(msg)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -776,11 +277,12 @@ const PodcastDashboard: React.FC = () => {
|
||||
budgetCap={projectState.budgetCap}
|
||||
avatarImageUrl={null}
|
||||
onUpdateJob={updateRenderJob}
|
||||
onUpdateScript={(updatedScript) => setScriptData(updatedScript)}
|
||||
onBack={() => {
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
}}
|
||||
onError={(msg) => setAnnouncement(msg)}
|
||||
onError={(msg) => workflow.setAnnouncement(msg)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -789,13 +291,13 @@ const PodcastDashboard: React.FC = () => {
|
||||
|
||||
{/* Preflight Block Dialog */}
|
||||
<PreflightBlockDialog
|
||||
open={showPreflightDialog}
|
||||
open={workflow.showPreflightDialog}
|
||||
onClose={() => {
|
||||
setShowPreflightDialog(false);
|
||||
setPreflightResponse(null);
|
||||
workflow.setShowPreflightDialog(false);
|
||||
workflow.setPreflightResponse(null);
|
||||
}}
|
||||
response={preflightResponse}
|
||||
operationName={preflightOperationName}
|
||||
response={workflow.preflightResponse}
|
||||
operationName={workflow.preflightOperationName}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, Button, CircularProgress, alpha } from "@mui/material";
|
||||
import React, { useCallback } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, alpha } from "@mui/material";
|
||||
import {
|
||||
PlayArrow as PlayArrowIcon,
|
||||
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";
|
||||
import { Script, Knobs, Job, RenderJobResult, TaskStatus } from "./types";
|
||||
import { podcastApi } from "../../services/podcastApi";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
|
||||
import { InlineAudioPlayer } from "./InlineAudioPlayer";
|
||||
import { Script, Knobs, Job } from "./types";
|
||||
import { SecondaryButton } from "./ui";
|
||||
import { SceneCard } from "./RenderQueue/SceneCard";
|
||||
import { SummaryStats } from "./RenderQueue/SummaryStats";
|
||||
import { GuidancePanel } from "./RenderQueue/GuidancePanel";
|
||||
import { useRenderQueue } from "./RenderQueue/useRenderQueue";
|
||||
|
||||
interface RenderQueueProps {
|
||||
projectId: string;
|
||||
@@ -27,307 +19,81 @@ interface RenderQueueProps {
|
||||
budgetCap?: number;
|
||||
avatarImageUrl?: string | null;
|
||||
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
|
||||
onUpdateScript?: (script: Script) => void;
|
||||
onBack: () => 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 [rendering, setRendering] = useState<string | null>(null);
|
||||
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const isBusy = Boolean(rendering);
|
||||
|
||||
// Cleanup polling intervals on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pollingIntervals.current.forEach((interval) => clearInterval(interval));
|
||||
pollingIntervals.current.clear();
|
||||
};
|
||||
const handleDownloadAudio = useCallback((audioUrl: string, title: string) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = audioUrl;
|
||||
link.download = `${title.replace(/\s+/g, "-")}.mp3`;
|
||||
link.click();
|
||||
}, []);
|
||||
|
||||
// Initialize jobs if empty
|
||||
useEffect(() => {
|
||||
if (jobs.length === 0 && script.scenes.length > 0) {
|
||||
const initialJobs: Job[] = script.scenes.map((s) => ({
|
||||
sceneId: s.id,
|
||||
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 handleDownloadVideo = useCallback((videoUrl: string, title: string) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = videoUrl;
|
||||
link.download = `${title.replace(/\s+/g, "-")}.mp4`;
|
||||
link.click();
|
||||
}, []);
|
||||
|
||||
const getScene = (sceneId: string) => script.scenes.find((s) => s.id === sceneId);
|
||||
|
||||
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) {
|
||||
const handleShare = useCallback(async (audioUrl: string, title: string) => {
|
||||
if (navigator.share && audioUrl) {
|
||||
try {
|
||||
await podcastApi.cancelTask(job.taskId);
|
||||
onUpdateJob(sceneId, { status: "cancelled", progress: 0 });
|
||||
|
||||
// Clear polling interval
|
||||
const interval = pollingIntervals.current.get(sceneId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
pollingIntervals.current.delete(sceneId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error cancelling task:", error);
|
||||
onError("Failed to cancel render job");
|
||||
await navigator.share({
|
||||
title,
|
||||
text: `Check out this podcast episode: ${title}`,
|
||||
url: audioUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
// User cancelled or error
|
||||
}
|
||||
} else {
|
||||
// Fallback: copy to clipboard
|
||||
await navigator.clipboard.writeText(audioUrl);
|
||||
alert("Audio URL copied to clipboard!");
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const runRender = async (sceneId: string, mode: "preview" | "full") => {
|
||||
// Prevent double-fire while another render is in-flight
|
||||
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;
|
||||
|
||||
// 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 />;
|
||||
}
|
||||
};
|
||||
const allScenesCompleted =
|
||||
(jobs.length > 0 && jobs.every((j) => j.status === "completed" && j.imageUrl)) ||
|
||||
(script.scenes.length > 0 && script.scenes.every((s) => s.audioUrl && s.imageUrl));
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
|
||||
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
|
||||
Back to Script
|
||||
@@ -349,204 +115,99 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({ projectId, script, kno
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Info Alert */}
|
||||
<Alert severity="info" sx={{ mb: 3, background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
|
||||
<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.
|
||||
</Typography>
|
||||
</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}>
|
||||
{jobs.map((job) => {
|
||||
const scene = getScene(job.sceneId);
|
||||
const initials = job.title
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
{script.scenes.map((scene) => {
|
||||
const job = jobs.find((j) => j.sceneId === scene.id);
|
||||
return (
|
||||
<GlassyCard key={job.sceneId} sx={glassyCardSx}>
|
||||
<Stack spacing={2}>
|
||||
<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 }}>
|
||||
{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>
|
||||
<SceneCard
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
job={job}
|
||||
rendering={rendering}
|
||||
generatingImage={generatingImage}
|
||||
isBusy={isBusy}
|
||||
avatarImageUrl={avatarImageUrl}
|
||||
onRender={runRender}
|
||||
onImageGenerate={runImageGeneration}
|
||||
onVideoRender={runVideoRender}
|
||||
onDownloadAudio={handleDownloadAudio}
|
||||
onDownloadVideo={handleDownloadVideo}
|
||||
onShare={handleShare}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ mt: 3, display: "flex", justifyContent: "flex-end" }}>
|
||||
<SecondaryButton onClick={onBack}>Done</SecondaryButton>
|
||||
</Box>
|
||||
{/* Footer - Video Generation Focus */}
|
||||
<Paper
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
415
frontend/src/components/PodcastMaker/RenderQueue/SceneCard.tsx
Normal file
415
frontend/src/components/PodcastMaker/RenderQueue/SceneCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export { SceneCard } from "./SceneCard";
|
||||
export { SceneActionButtons } from "./SceneActionButtons";
|
||||
export { SummaryStats } from "./SummaryStats";
|
||||
export { GuidancePanel } from "./GuidancePanel";
|
||||
export { useRenderQueue } from "./useRenderQueue";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Stack, Box, Typography, TextField, Button, Chip, CircularProgress, alpha } from "@mui/material";
|
||||
import { VolumeUp as VolumeUpIcon } from "@mui/icons-material";
|
||||
import { Stack, Box, Typography, TextField, Button, Chip, alpha } from "@mui/material";
|
||||
import { Line } from "../types";
|
||||
import { GlassyCard, glassyCardSx } from "../ui";
|
||||
|
||||
interface LineEditorProps {
|
||||
line: Line;
|
||||
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 [text, setText] = useState(line.text);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
useEffect(() => setText(line.text), [line.text]);
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -21,33 +18,37 @@ export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPrevie
|
||||
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 (
|
||||
<GlassyCard
|
||||
whileHover={{ y: -2 }}
|
||||
sx={{
|
||||
...glassyCardSx,
|
||||
p: 2,
|
||||
transition: "all 0.2s",
|
||||
p: 2.5,
|
||||
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 direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<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 ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
@@ -57,47 +58,97 @@ export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPrevie
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
color: "white",
|
||||
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
|
||||
color: "#0f172a",
|
||||
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}
|
||||
</Typography>
|
||||
)}
|
||||
{line.usedFactIds && line.usedFactIds.length > 0 && (
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 1.5 }} flexWrap="wrap" useFlexGap>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.75rem" }}>
|
||||
Facts:
|
||||
</Typography>
|
||||
{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>
|
||||
)}
|
||||
</Box>
|
||||
<Stack spacing={1} sx={{ ml: 2 }}>
|
||||
<Box sx={{ ml: 2 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant={editing ? "contained" : "outlined"}
|
||||
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"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={previewing ? <CircularProgress size={14} /> : <VolumeUpIcon />}
|
||||
onClick={handlePreview}
|
||||
disabled={previewing || editing}
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
Preview TTS
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
|
||||
@@ -1,84 +1,377 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, alpha } from "@mui/material";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress } from "@mui/material";
|
||||
import {
|
||||
EditNote as EditNoteIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
RadioButtonUnchecked as RadioButtonUncheckedIcon,
|
||||
VolumeUp as VolumeUpIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Image as ImageIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Scene, Line } from "../types";
|
||||
import { Scene, Line, Knobs } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { LineEditor } from "./LineEditor";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
import { aiApiClient } from "../../../api/client";
|
||||
|
||||
interface SceneEditorProps {
|
||||
scene: Scene;
|
||||
onUpdateScene: (s: Scene) => void;
|
||||
onApprove: (id: string) => Promise<void>;
|
||||
onPreviewLine: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
|
||||
knobs: Knobs;
|
||||
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> = ({
|
||||
scene,
|
||||
onUpdateScene,
|
||||
onApprove,
|
||||
onPreviewLine,
|
||||
knobs,
|
||||
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 updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) };
|
||||
onUpdateScene(updated);
|
||||
};
|
||||
const approving = approvingSceneId === scene.id;
|
||||
|
||||
const handleApprove = async () => {
|
||||
await onApprove(scene.id);
|
||||
onUpdateScene({ ...scene, approved: true });
|
||||
const approving = approvingSceneId === scene.id;
|
||||
const generating = generatingAudioId === scene.id || localGenerating;
|
||||
const hasAudio = Boolean(scene.audioUrl && audioBlobUrl);
|
||||
const hasImage = Boolean(scene.imageUrl);
|
||||
|
||||
const handleApproveAndGenerate = async () => {
|
||||
const wasAlreadyApproved = scene.approved;
|
||||
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 (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={2}>
|
||||
<Stack spacing={2.5}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
|
||||
<EditNoteIcon fontSize="small" />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
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}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
|
||||
<Chip
|
||||
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
|
||||
label={scene.approved ? "Approved" : "Pending Approval"}
|
||||
size="small"
|
||||
color={scene.approved ? "success" : "warning"}
|
||||
sx={{
|
||||
background: scene.approved ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
|
||||
color: scene.approved ? "#6ee7b7" : "#fbbf24",
|
||||
border: scene.approved ? "1px solid rgba(16,185,129,0.3)" : "1px solid rgba(245,158,11,0.3)",
|
||||
background: scene.approved
|
||||
? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)"
|
||||
: "linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(217, 119, 6, 0.12) 100%)",
|
||||
color: scene.approved ? "#059669" : "#d97706",
|
||||
border: scene.approved
|
||||
? "1px solid rgba(16, 185, 129, 0.25)"
|
||||
: "1px solid rgba(245, 158, 11, 0.25)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem" }}>
|
||||
Duration: {scene.duration}s
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<PrimaryButton
|
||||
onClick={handleApprove}
|
||||
disabled={scene.approved || approving}
|
||||
loading={approving}
|
||||
startIcon={scene.approved ? <CheckCircleIcon /> : undefined}
|
||||
tooltip={scene.approved ? "Scene is approved and ready for rendering" : "Approve this scene to enable rendering"}
|
||||
>
|
||||
{scene.approved ? "Approved" : approving ? "Approving..." : "Approve Scene"}
|
||||
</PrimaryButton>
|
||||
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
|
||||
<PrimaryButton
|
||||
onClick={handleApproveAndGenerate}
|
||||
disabled={approving || generating}
|
||||
loading={approving || generating}
|
||||
startIcon={
|
||||
hasAudio && !generating ? (
|
||||
<VolumeUpIcon />
|
||||
) : 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>
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
|
||||
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1 }} />
|
||||
|
||||
<Stack spacing={2}>
|
||||
{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>
|
||||
|
||||
{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>
|
||||
</GlassyCard>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha } from "@mui/material";
|
||||
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon } from "@mui/icons-material";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
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, 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 { BlogResearchResponse } from "../../../services/blogWriterApi";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
|
||||
import { SceneEditor } from "./SceneEditor";
|
||||
import { InlineAudioPlayer } from "../InlineAudioPlayer";
|
||||
import { aiApiClient } from "../../../api/client";
|
||||
|
||||
interface ScriptEditorProps {
|
||||
projectId: string;
|
||||
@@ -40,6 +42,15 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
|
||||
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
|
||||
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(true);
|
||||
const [combiningAudio, setCombiningAudio] = useState(false);
|
||||
const [combinedAudioResult, setCombinedAudioResult] = useState<{
|
||||
url: string;
|
||||
filename: string;
|
||||
duration: number;
|
||||
sceneCount: number;
|
||||
} | null>(null);
|
||||
|
||||
// Sync with parent state
|
||||
useEffect(() => {
|
||||
@@ -90,26 +101,32 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes]);
|
||||
|
||||
const updateScene = (updated: Scene) => {
|
||||
if (!script) return;
|
||||
const updatedScript = { ...script, scenes: script.scenes.map((s) => (s.id === updated.id ? updated : s)) };
|
||||
setScript(updatedScript);
|
||||
onScriptChange(updatedScript);
|
||||
// Use functional update to ensure we're working with latest state
|
||||
setScript((currentScript) => {
|
||||
if (!currentScript) return currentScript;
|
||||
const updatedScript = {
|
||||
...currentScript,
|
||||
scenes: currentScript.scenes.map((s) => (s.id === updated.id ? { ...s, ...updated } : s))
|
||||
};
|
||||
onScriptChange(updatedScript);
|
||||
return updatedScript;
|
||||
});
|
||||
};
|
||||
|
||||
const approveScene = async (sceneId: string) => {
|
||||
try {
|
||||
setApprovingSceneId(sceneId);
|
||||
await podcastApi.approveScene({ projectId, sceneId });
|
||||
const updatedScript = script
|
||||
? {
|
||||
...script,
|
||||
scenes: script.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
|
||||
}
|
||||
: null;
|
||||
if (updatedScript) {
|
||||
setScript(updatedScript);
|
||||
// Use functional update to ensure we're working with latest state
|
||||
setScript((currentScript) => {
|
||||
if (!currentScript) return currentScript;
|
||||
const updatedScript = {
|
||||
...currentScript,
|
||||
scenes: currentScript.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
|
||||
};
|
||||
onScriptChange(updatedScript);
|
||||
}
|
||||
return updatedScript;
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to approve scene";
|
||||
setError(message);
|
||||
@@ -124,46 +141,404 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
const approvedCount = script ? script.scenes.filter((s) => s.approved).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 (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
|
||||
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
|
||||
Back to Research
|
||||
</SecondaryButton>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
fontWeight: 800,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<EditNoteIcon />
|
||||
Script Editor
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.02em",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
fontSize: { xs: "1.75rem", md: "2rem" },
|
||||
}}
|
||||
>
|
||||
<EditNoteIcon sx={{ fontSize: "2rem" }} />
|
||||
Script Editor
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
|
||||
Review and refine your podcast script before rendering
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{loading && (
|
||||
<Alert severity="info" icon={<CircularProgress size={20} />} sx={{ mb: 3 }}>
|
||||
<Typography variant="body2">Generating script with AI... This may take a moment.</Typography>
|
||||
<Alert
|
||||
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>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
<Alert
|
||||
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>
|
||||
)}
|
||||
|
||||
{script && (
|
||||
<Stack spacing={3}>
|
||||
<Alert severity="info" sx={{ background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Approval Required:</strong> Each scene must be approved before rendering. Review and edit lines as needed, then approve each scene.
|
||||
{/* Script Format Explanation Panel */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)",
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(99, 102, 241, 0.08)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: showScriptFormatInfo ? 2 : 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
|
||||
}}
|
||||
>
|
||||
<InfoIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
|
||||
Why This Script Format?
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
|
||||
Understanding how your script creates natural, human-like audio
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<IconButton
|
||||
onClick={() => setShowScriptFormatInfo(!showScriptFormatInfo)}
|
||||
sx={{
|
||||
color: "#6366f1",
|
||||
"&:hover": {
|
||||
background: "rgba(99, 102, 241, 0.1)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showScriptFormatInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={showScriptFormatInfo}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.8, mb: 2 }}>
|
||||
Our AI script generator creates scripts specifically optimized for <strong style={{ fontWeight: 600 }}>high-quality text-to-speech</strong>.
|
||||
The format you see here is designed to produce audio that sounds natural and human-like, not robotic.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
1
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Natural Pauses & Rhythm
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
The script includes strategic pauses between lines and when speakers change. This creates natural breathing patterns
|
||||
and conversation flow, just like real human speech. Without these pauses, the audio would sound rushed and robotic.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
2
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Emphasis Markers
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
Lines marked with emphasis help highlight important points, statistics, or key insights. The AI voice will naturally
|
||||
stress these parts, making your podcast more engaging and easier to follow—just like a real host would emphasize important information.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
3
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Short, Conversational Sentences
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
The script uses shorter sentences (15-20 words) written in a conversational style. This matches how people actually
|
||||
speak, making the audio sound more natural. Long, complex sentences would sound awkward when spoken aloud.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
4
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Scene-Specific Emotions
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
Each scene has an emotional tone (excited, serious, curious, etc.) that guides the AI voice's delivery. This creates
|
||||
variety and keeps listeners engaged, just like a real podcast host would vary their tone based on the topic.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
5
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Optimized for Podcast Narration
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
The script is optimized with slightly slower pacing and natural pronunciation settings specifically for podcast narration.
|
||||
This ensures clarity and makes the content easy to understand, even when listeners are multitasking.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mt: 1,
|
||||
background: "rgba(99, 102, 241, 0.06)",
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
"& .MuiAlert-icon": {
|
||||
color: "#6366f1",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
|
||||
<strong style={{ fontWeight: 600 }}>Tip:</strong> You can edit any line or scene to match your preferences.
|
||||
The format will be preserved when rendering, ensuring your audio still sounds natural and professional.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
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>
|
||||
</Alert>
|
||||
|
||||
@@ -179,8 +554,27 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
scene={scene}
|
||||
onUpdateScene={updateScene}
|
||||
onApprove={approveScene}
|
||||
onPreviewLine={(text) => podcastApi.previewLine(text)}
|
||||
knobs={knobs}
|
||||
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>
|
||||
))}
|
||||
@@ -188,39 +582,187 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: alpha("#1e293b", 0.6),
|
||||
border: allApproved ? "2px solid rgba(16,185,129,0.4)" : "1px solid rgba(255,255,255,0.1)",
|
||||
p: 3.5,
|
||||
background: allApproved
|
||||
? "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">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 0.5, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<CheckCircleIcon fontSize="small" color={allApproved ? "success" : "disabled"} />
|
||||
<Typography variant="subtitle1" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 1.5, color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
|
||||
<CheckCircleIcon fontSize="small" sx={{ color: allApproved ? "#10b981" : "#94a3b8", fontSize: "1.25rem" }} />
|
||||
Approval Status
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 400, lineHeight: 1.6 }}>
|
||||
{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>
|
||||
{!allApproved && (
|
||||
{!allScenesHaveAudioAndImages && (
|
||||
<LinearProgress
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<PrimaryButton
|
||||
onClick={() => script && onProceedToRendering(script)}
|
||||
disabled={!allApproved}
|
||||
disabled={!allScenesHaveAudioAndImages}
|
||||
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
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</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>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -30,6 +30,11 @@ export type Research = {
|
||||
why: string;
|
||||
mappedFactIds: string[];
|
||||
}[];
|
||||
searchQueries?: string[];
|
||||
searchType?: string;
|
||||
provider?: string;
|
||||
cost?: number;
|
||||
sourceCount?: number;
|
||||
};
|
||||
|
||||
export type Line = {
|
||||
@@ -37,6 +42,7 @@ export type Line = {
|
||||
speaker: string;
|
||||
text: string;
|
||||
usedFactIds?: string[];
|
||||
emphasis?: boolean; // Mark lines that need vocal emphasis
|
||||
};
|
||||
|
||||
export type Scene = {
|
||||
@@ -45,6 +51,9 @@ export type Scene = {
|
||||
duration: number;
|
||||
lines: Line[];
|
||||
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 = {
|
||||
@@ -75,6 +84,7 @@ export type Job = {
|
||||
voiceId?: string | null;
|
||||
fileSize?: number | null;
|
||||
avatarImageUrl?: string | null;
|
||||
imageUrl?: string | null; // Scene-specific image URL
|
||||
};
|
||||
|
||||
export type PodcastAnalysis = {
|
||||
@@ -84,6 +94,15 @@ export type PodcastAnalysis = {
|
||||
suggestedOutlines: { id: number | string; title: string; segments: string[] }[];
|
||||
suggestedKnobs: Knobs;
|
||||
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 = {
|
||||
|
||||
@@ -5,10 +5,16 @@ import { Paper, alpha } from "@mui/material";
|
||||
export const GlassyCard = motion(Paper);
|
||||
|
||||
export const glassyCardSx = {
|
||||
borderRadius: 2,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 3,
|
||||
border: "1px solid rgba(15, 23, 42, 0.06)",
|
||||
background: "#ffffff",
|
||||
p: 2.5,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
p: 3,
|
||||
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)",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Button, CircularProgress, Tooltip, alpha } from "@mui/material";
|
||||
import { Button, CircularProgress, Tooltip, alpha, SxProps, Theme } from "@mui/material";
|
||||
|
||||
interface PrimaryButtonProps {
|
||||
children: React.ReactNode;
|
||||
@@ -9,6 +9,7 @@ interface PrimaryButtonProps {
|
||||
startIcon?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
ariaLabel?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
@@ -19,6 +20,7 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
startIcon,
|
||||
tooltip,
|
||||
ariaLabel,
|
||||
sx,
|
||||
}) => {
|
||||
const button = (
|
||||
<Button
|
||||
@@ -41,6 +43,7 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
background: alpha("#9ca3af", 0.3),
|
||||
color: alpha("#fff", 0.5),
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
import { Button, Tooltip, alpha } from "@mui/material";
|
||||
import { Button, Tooltip, CircularProgress, alpha } from "@mui/material";
|
||||
|
||||
interface SecondaryButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
startIcon?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
ariaLabel?: string;
|
||||
@@ -14,6 +15,7 @@ export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
startIcon,
|
||||
tooltip,
|
||||
ariaLabel,
|
||||
@@ -22,8 +24,8 @@ export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
startIcon={startIcon}
|
||||
disabled={disabled || loading}
|
||||
startIcon={loading ? <CircularProgress size={16} /> : startIcon}
|
||||
aria-label={ariaLabel}
|
||||
sx={{
|
||||
borderColor: "rgba(255,255,255,0.2)",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { Suspense } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -6,10 +6,17 @@ import {
|
||||
Box,
|
||||
Grid,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TrendingUp as TrendingUpIcon,
|
||||
TrendingDown as TrendingDownIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
LineChart,
|
||||
LazyLineChart,
|
||||
LazyAreaChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
@@ -17,13 +24,8 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
AreaChart
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
ChartLoadingFallback
|
||||
} from '../../utils/lazyRecharts';
|
||||
|
||||
// Types
|
||||
import { UsageTrends as UsageTrendsType, CostProjections } from '../../types/billing';
|
||||
@@ -113,7 +115,7 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
{/* Header */}
|
||||
<CardContent sx={{ pb: 1 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold', mb: 2 }}>
|
||||
<TrendingUp size={20} />
|
||||
<TrendingUpIcon fontSize="small" />
|
||||
Usage Trends & Projections
|
||||
</Typography>
|
||||
</CardContent>
|
||||
@@ -138,9 +140,9 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
|
||||
{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">
|
||||
Cost Growth
|
||||
@@ -176,9 +178,9 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
|
||||
{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">
|
||||
Calls Growth
|
||||
@@ -205,7 +207,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
</Typography>
|
||||
<Box sx={{ height: 200 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<LazyAreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="costGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#667eea" stopOpacity={0.3}/>
|
||||
@@ -234,7 +237,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
fillOpacity={1}
|
||||
fill="url(#costGradient)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</LazyAreaChart>
|
||||
</Suspense>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -246,7 +250,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
</Typography>
|
||||
<Box sx={{ height: 150 }}>
|
||||
<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)" />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
@@ -267,7 +272,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
dot={{ fill: '#764ba2', strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: '#764ba2', strokeWidth: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</LazyLineChart>
|
||||
</Suspense>
|
||||
</ResponsiveContainer>
|
||||
</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 }}>
|
||||
<Calendar size={16} />
|
||||
<CalendarIcon fontSize="small" />
|
||||
Monthly Projections
|
||||
</Typography>
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ const DEFAULT_STATE: PodcastProjectState = {
|
||||
scriptData: null,
|
||||
renderJobs: [],
|
||||
knobs: DEFAULT_KNOBS,
|
||||
researchProvider: "google",
|
||||
researchProvider: "exa",
|
||||
budgetCap: 50,
|
||||
showScriptEditor: false,
|
||||
showRenderQueue: false,
|
||||
@@ -327,7 +327,7 @@ export const usePodcastProjectState = () => {
|
||||
scriptData: dbProject.script_data,
|
||||
renderJobs: dbProject.render_jobs || [],
|
||||
knobs: dbProject.knobs || DEFAULT_KNOBS,
|
||||
researchProvider: dbProject.research_provider || 'google',
|
||||
researchProvider: dbProject.research_provider || 'exa',
|
||||
budgetCap: dbProject.budget_cap || 50,
|
||||
showScriptEditor: dbProject.show_script_editor || false,
|
||||
showRenderQueue: dbProject.show_render_queue || false,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse, ResearchProvider } from "./blogWriterApi";
|
||||
import { ResearchProvider, ResearchConfig } from "./blogWriterApi";
|
||||
import {
|
||||
storyWriterApi,
|
||||
StoryGenerationRequest,
|
||||
StoryScene,
|
||||
StorySetupGenerationResponse,
|
||||
} from "./storyWriterApi";
|
||||
@@ -22,11 +21,8 @@ import {
|
||||
Script,
|
||||
} from "../components/PodcastMaker/types";
|
||||
import { checkPreflight, PreflightOperation } from "./billingService";
|
||||
import { TaskStatusResponse } from "./blogWriterApi";
|
||||
import { TaskStatus } from "./storyWriterApi";
|
||||
|
||||
type WaitForTaskFn = (taskId: string) => Promise<TaskStatusResponse>;
|
||||
|
||||
const DEFAULT_KNOBS: Knobs = {
|
||||
voice_emotion: "neutral",
|
||||
voice_speed: 1,
|
||||
@@ -36,7 +32,7 @@ const DEFAULT_KNOBS: Knobs = {
|
||||
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) => {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
@@ -125,33 +121,53 @@ const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string):
|
||||
return generated.slice(0, 6);
|
||||
};
|
||||
|
||||
const mapSourcesToFacts = (sources: BlogResearchResponse["sources"]): Fact[] => {
|
||||
const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => {
|
||||
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"),
|
||||
quote: source.excerpt || source.title || "Insight",
|
||||
url: source.url || "",
|
||||
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 summary =
|
||||
response.keyword_analysis?.summary ||
|
||||
response.keyword_analysis?.key_insights?.join(" • ") ||
|
||||
"Research completed. Review fact cards for details.";
|
||||
const mappedAngles =
|
||||
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),
|
||||
})) || [];
|
||||
response.content?.slice(0, 1200) ||
|
||||
(response.search_queries && response.search_queries.length
|
||||
? `Research completed for queries: ${response.search_queries.join(", ")}`
|
||||
: "Research completed. Review fact cards for details.");
|
||||
return {
|
||||
summary,
|
||||
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 => {
|
||||
const text = scene.description || scene.audio_narration || scene.image_prompt || scene.title || "Narration";
|
||||
return {
|
||||
id: `scene-${scene.scene_number || createId("scene")}`,
|
||||
title: scene.title || `Scene ${scene.scene_number}`,
|
||||
duration: Math.max(20, knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target),
|
||||
lines: splitIntoLines(text, Math.max(1, speakers)),
|
||||
approved: false,
|
||||
};
|
||||
};
|
||||
// Unused helper functions - kept for reference but not currently used
|
||||
// const storySceneToPodcastScene = (scene: StoryScene, knobs: Knobs, speakers: number): Scene => {
|
||||
// const text = scene.description || scene.audio_narration || scene.image_prompt || scene.title || "Narration";
|
||||
// return {
|
||||
// id: `scene-${scene.scene_number || createId("scene")}`,
|
||||
// title: scene.title || `Scene ${scene.scene_number}`,
|
||||
// duration: Math.max(20, knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target),
|
||||
// lines: splitIntoLines(text, Math.max(1, speakers)),
|
||||
// approved: false,
|
||||
// };
|
||||
// };
|
||||
|
||||
const ensureScenes = (outline: StorySetupGenerationResponse["options"] | StoryScene[] | string | undefined): StoryScene[] => {
|
||||
if (!outline) return [];
|
||||
if (typeof outline === "string") {
|
||||
return [
|
||||
{
|
||||
scene_number: 1,
|
||||
title: outline.slice(0, 60),
|
||||
description: outline,
|
||||
image_prompt: outline,
|
||||
audio_narration: outline,
|
||||
} as StoryScene,
|
||||
];
|
||||
}
|
||||
if (Array.isArray(outline)) {
|
||||
return outline as StoryScene[];
|
||||
}
|
||||
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 ensureScenes = (outline: StorySetupGenerationResponse["options"] | StoryScene[] | string | undefined): StoryScene[] => {
|
||||
// if (!outline) return [];
|
||||
// if (typeof outline === "string") {
|
||||
// return [
|
||||
// {
|
||||
// scene_number: 1,
|
||||
// title: outline.slice(0, 60),
|
||||
// description: outline,
|
||||
// image_prompt: outline,
|
||||
// audio_narration: outline,
|
||||
// } as StoryScene,
|
||||
// ];
|
||||
// }
|
||||
// if (Array.isArray(outline)) {
|
||||
// return outline as StoryScene[];
|
||||
// }
|
||||
// return [];
|
||||
// };
|
||||
|
||||
const ensurePreflight = async (operation: PreflightOperation) => {
|
||||
const result = await checkPreflight(operation);
|
||||
@@ -275,6 +256,7 @@ export const podcastApi = {
|
||||
suggestedOutlines: outlines,
|
||||
suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs },
|
||||
titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean),
|
||||
exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined,
|
||||
};
|
||||
|
||||
const researchConfig = await getResearchConfig().catch(() => null);
|
||||
@@ -303,62 +285,53 @@ export const podcastApi = {
|
||||
topic: string;
|
||||
approvedQueries: Query[];
|
||||
provider?: ResearchProvider;
|
||||
exaConfig?: ResearchConfig;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ research: Research; raw: BlogResearchResponse }> {
|
||||
}): Promise<{ research: Research; raw: any }> {
|
||||
const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean);
|
||||
if (!keywords.length) {
|
||||
throw new Error("At least one query must be approved for research.");
|
||||
}
|
||||
|
||||
const researchPayload: BlogResearchRequest = {
|
||||
keywords,
|
||||
topic: params.topic || keywords[0],
|
||||
research_mode: "basic",
|
||||
config: {
|
||||
provider: params.provider || "google",
|
||||
include_statistics: params.approvedQueries.some((q) => q.needsRecentStats),
|
||||
},
|
||||
};
|
||||
// Ensure Exa payload respects API constraint: when requesting contents, only one of includeDomains or excludeDomains.
|
||||
let sanitizedExaConfig: ResearchConfig | undefined = params.exaConfig;
|
||||
if (sanitizedExaConfig && sanitizedExaConfig.exa_include_domains?.length) {
|
||||
sanitizedExaConfig = {
|
||||
...sanitizedExaConfig,
|
||||
exa_exclude_domains: undefined,
|
||||
};
|
||||
} else if (sanitizedExaConfig && sanitizedExaConfig.exa_exclude_domains?.length) {
|
||||
sanitizedExaConfig = {
|
||||
...sanitizedExaConfig,
|
||||
exa_include_domains: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
await ensurePreflight({
|
||||
provider: params.provider === "exa" ? "exa" : "gemini",
|
||||
operation_type: params.provider === "exa" ? "exa_neural_search" : "google_grounding",
|
||||
tokens_requested: params.provider === "exa" ? 0 : 1200,
|
||||
actual_provider_name: params.provider || "google",
|
||||
provider: "exa",
|
||||
operation_type: "exa_neural_search",
|
||||
tokens_requested: 0,
|
||||
actual_provider_name: "exa",
|
||||
});
|
||||
|
||||
const { task_id } = await blogWriterApi.startResearch(researchPayload);
|
||||
let lastProgressMessage = "";
|
||||
const result = (await waitForTaskCompletion(
|
||||
task_id,
|
||||
blogWriterApi.pollResearchStatus,
|
||||
(status) => {
|
||||
// Extract latest progress message and notify caller
|
||||
if (status.message && status.message !== lastProgressMessage) {
|
||||
lastProgressMessage = status.message;
|
||||
if (params.onProgress) {
|
||||
params.onProgress(status.message);
|
||||
}
|
||||
} 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 };
|
||||
const response = await aiApiClient.post("/api/podcast/research/exa", {
|
||||
topic: params.topic || keywords[0],
|
||||
queries: keywords,
|
||||
exa_config: sanitizedExaConfig,
|
||||
});
|
||||
|
||||
const exaResult = response.data as ExaResearchResult;
|
||||
if (params.onProgress) {
|
||||
params.onProgress("Deep research completed with Exa.");
|
||||
}
|
||||
const mapped = mapExaResearchResponse(exaResult);
|
||||
return { research: mapped, raw: exaResult };
|
||||
},
|
||||
|
||||
async generateScript(params: {
|
||||
projectId: string;
|
||||
idea: string;
|
||||
research?: BlogResearchResponse | null;
|
||||
research?: ExaResearchResult | null;
|
||||
knobs: Knobs;
|
||||
speakers: number;
|
||||
durationMinutes: number;
|
||||
@@ -433,22 +406,96 @@ export const podcastApi = {
|
||||
};
|
||||
},
|
||||
|
||||
async renderSceneAudio(params: { scene: Scene; voiceId?: string; emotion?: string; speed?: number }): Promise<RenderJobResult> {
|
||||
const text = params.scene.lines.map((line) => line.text).join(" ");
|
||||
async renderSceneAudio(params: {
|
||||
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({
|
||||
provider: "audio",
|
||||
operation_type: "tts_full_render",
|
||||
tokens_requested: text.length,
|
||||
tokens_requested: textToUse.length,
|
||||
actual_provider_name: "wavespeed",
|
||||
});
|
||||
|
||||
const response = await aiApiClient.post("/api/podcast/audio", {
|
||||
scene_id: params.scene.id,
|
||||
scene_title: params.scene.title,
|
||||
text,
|
||||
text: textToUse,
|
||||
voice_id: params.voiceId || "Wise_Woman",
|
||||
speed: params.speed || 1.0,
|
||||
emotion: params.emotion || "neutral",
|
||||
speed: params.speed || 1.0, // Normal speed (was 0.9, but too slow - causing duration issues)
|
||||
emotion: sceneEmotion,
|
||||
english_normalization: true, // Better number reading for statistics
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -578,6 +625,35 @@ export const podcastApi = {
|
||||
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> {
|
||||
// Note: Task cancellation may not be fully supported by backend yet
|
||||
// This is a placeholder for future implementation
|
||||
@@ -587,6 +663,25 @@ export const podcastApi = {
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* 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');
|
||||
|
||||
/* Smooth scrolling */
|
||||
|
||||
77
frontend/src/utils/lazyRecharts.tsx
Normal file
77
frontend/src/utils/lazyRecharts.tsx
Normal 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';
|
||||
|
||||
10
frontend/src/utils/lazyWix.ts
Normal file
10
frontend/src/utils/lazyWix.ts
Normal 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');
|
||||
|
||||
BIN
podcast_images/scene_scene1_Opening_Hook_ec1e050b.png
Normal file
BIN
podcast_images/scene_scene1_Opening_Hook_ec1e050b.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
Reference in New Issue
Block a user