Analyzing your idea with AI...
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,6 +4,10 @@ __pycache__/
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite*
|
*.sqlite*
|
||||||
|
|
||||||
|
nul
|
||||||
|
LICENSE
|
||||||
|
CHANGELOG.md
|
||||||
|
|
||||||
.trae/
|
.trae/
|
||||||
.trae
|
.trae
|
||||||
|
|
||||||
|
|||||||
14
README.md
Normal file
14
README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Render CLI
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
- [Homebrew](https://render.com/docs/cli#homebrew-macos-linux)
|
||||||
|
- [Direct Download](https://render.com/docs/cli#direct-download)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Documentation is hosted at https://render.com/docs/cli.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
To create a new command, use the `cmd/template.go` template file as a starting point. Reference the [CLI Style Guide](docs/STYLE.md) to learn more about command naming, flags, arguments, and help text conventions.
|
||||||
672
_session_backup/App.tsx
Normal file
672
_session_backup/App.tsx
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||||
|
import { CopilotKit } from "@copilotkit/react-core";
|
||||||
|
import { ClerkProvider, useAuth } from '@clerk/clerk-react';
|
||||||
|
import "@copilotkit/react-ui/styles.css";
|
||||||
|
import Wizard from './components/OnboardingWizard/Wizard';
|
||||||
|
import MainDashboard from './components/MainDashboard/MainDashboard';
|
||||||
|
import SEODashboard from './components/SEODashboard/SEODashboard';
|
||||||
|
import ContentPlanningDashboard from './components/ContentPlanningDashboard/ContentPlanningDashboard';
|
||||||
|
import FacebookWriter from './components/FacebookWriter/FacebookWriter';
|
||||||
|
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
||||||
|
import BlogWriter from './components/BlogWriter/BlogWriter';
|
||||||
|
import StoryWriter from './components/StoryWriter/StoryWriter';
|
||||||
|
import { StoryProjectList } from './components/StoryWriter/StoryProjectList';
|
||||||
|
import YouTubeCreator from './components/YouTubeCreator/YouTubeCreator';
|
||||||
|
import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard, FaceSwapStudio, CompressionStudio, ImageProcessingStudio } from './components/ImageStudio';
|
||||||
|
import {
|
||||||
|
VideoStudioDashboard,
|
||||||
|
CreateVideo,
|
||||||
|
AvatarVideo,
|
||||||
|
EnhanceVideo,
|
||||||
|
ExtendVideo,
|
||||||
|
EditVideo,
|
||||||
|
TransformVideo,
|
||||||
|
SocialVideo,
|
||||||
|
FaceSwap,
|
||||||
|
VideoTranslate,
|
||||||
|
VideoBackgroundRemover,
|
||||||
|
AddAudioToVideo,
|
||||||
|
LibraryVideo,
|
||||||
|
} from './components/VideoStudio';
|
||||||
|
import {
|
||||||
|
ProductMarketingDashboard,
|
||||||
|
ProductPhotoshootStudio,
|
||||||
|
ProductAnimationStudio,
|
||||||
|
ProductVideoStudio,
|
||||||
|
ProductAvatarStudio,
|
||||||
|
} from './components/ProductMarketing';
|
||||||
|
import PodcastDashboard from './components/PodcastMaker/PodcastDashboard';
|
||||||
|
import PricingPage from './components/Pricing/PricingPage';
|
||||||
|
import WixTestPage from './components/WixTestPage/WixTestPage';
|
||||||
|
import WixCallbackPage from './components/WixCallbackPage/WixCallbackPage';
|
||||||
|
import WordPressCallbackPage from './components/WordPressCallbackPage/WordPressCallbackPage';
|
||||||
|
import BingCallbackPage from './components/BingCallbackPage/BingCallbackPage';
|
||||||
|
import BingAnalyticsStorage from './components/BingAnalyticsStorage/BingAnalyticsStorage';
|
||||||
|
import ResearchDashboard from './pages/ResearchDashboard';
|
||||||
|
import IntentResearchTest from './pages/IntentResearchTest';
|
||||||
|
import SchedulerDashboard from './pages/SchedulerDashboard';
|
||||||
|
import BillingPage from './pages/BillingPage';
|
||||||
|
import ApprovalsPage from './pages/ApprovalsPage';
|
||||||
|
import TeamActivityPage from './pages/TeamActivityPage';
|
||||||
|
import StripeDisputesDashboard from './pages/StripeDisputesDashboard';
|
||||||
|
import ProtectedRoute from './components/shared/ProtectedRoute';
|
||||||
|
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
|
||||||
|
import Landing from './components/Landing/Landing';
|
||||||
|
import ErrorBoundary from './components/shared/ErrorBoundary';
|
||||||
|
import ErrorBoundaryTest from './components/shared/ErrorBoundaryTest';
|
||||||
|
import CopilotKitDegradedBanner from './components/shared/CopilotKitDegradedBanner';
|
||||||
|
import { OnboardingProvider } from './contexts/OnboardingContext';
|
||||||
|
import { SubscriptionProvider, useSubscription } from './contexts/SubscriptionContext';
|
||||||
|
import { CopilotKitHealthProvider } from './contexts/CopilotKitHealthContext';
|
||||||
|
import { useOAuthTokenAlerts } from './hooks/useOAuthTokenAlerts';
|
||||||
|
|
||||||
|
import { setAuthTokenGetter, setClerkSignOut } from './api/client';
|
||||||
|
import { setMediaAuthTokenGetter } from './utils/fetchMediaBlobUrl';
|
||||||
|
import { setBillingAuthTokenGetter } from './services/billingService';
|
||||||
|
import { useOnboarding } from './contexts/OnboardingContext';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import ConnectionErrorPage from './components/shared/ConnectionErrorPage';
|
||||||
|
import { isPodcastOnlyDemoMode } from './utils/demoMode';
|
||||||
|
|
||||||
|
// interface OnboardingStatus {
|
||||||
|
// onboarding_required: boolean;
|
||||||
|
// onboarding_complete: boolean;
|
||||||
|
// current_step?: number;
|
||||||
|
// total_steps?: number;
|
||||||
|
// completion_percentage?: number;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Conditional CopilotKit wrapper that only shows sidebar on content-planning route
|
||||||
|
const ConditionalCopilotKit: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
// Do not render CopilotSidebar here. Let specific pages/components control it.
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrapper to only enable CopilotKit checks/provider when user is authenticated
|
||||||
|
// This prevents CopilotKit from running on the Landing page
|
||||||
|
const AuthenticatedCopilotWrapper: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
apiKey: string;
|
||||||
|
}> = ({ children, apiKey }) => {
|
||||||
|
const { isSignedIn } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Exclude CopilotKit from running on:
|
||||||
|
// 1. Landing page (handled by !isSignedIn)
|
||||||
|
// 2. Onboarding pages (to prevent health check timeouts)
|
||||||
|
// 3. Podcast-only demo mode (CopilotKit not needed)
|
||||||
|
const isPodcastOnly = isPodcastOnlyDemoMode();
|
||||||
|
const shouldExcludeCopilot = !isSignedIn || location.pathname.startsWith('/onboarding') || isPodcastOnly;
|
||||||
|
|
||||||
|
if (shouldExcludeCopilot) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasKey = apiKey && apiKey.trim();
|
||||||
|
|
||||||
|
if (hasKey) {
|
||||||
|
// Enhanced error handler that updates health context
|
||||||
|
const handleCopilotKitError = (e: any) => {
|
||||||
|
console.error("CopilotKit Error:", e);
|
||||||
|
|
||||||
|
// Try to get health context if available
|
||||||
|
// We'll use a custom event to notify health context since we can't access it directly here
|
||||||
|
const errorMessage = e?.error?.message || e?.message || 'CopilotKit error occurred';
|
||||||
|
const errorType = errorMessage.toLowerCase();
|
||||||
|
|
||||||
|
// Differentiate between fatal and transient errors
|
||||||
|
const isFatalError =
|
||||||
|
errorType.includes('cors') ||
|
||||||
|
errorType.includes('ssl') ||
|
||||||
|
errorType.includes('certificate') ||
|
||||||
|
errorType.includes('403') ||
|
||||||
|
errorType.includes('forbidden') ||
|
||||||
|
errorType.includes('ERR_CERT_COMMON_NAME_INVALID');
|
||||||
|
|
||||||
|
// Dispatch event for health context to listen to
|
||||||
|
window.dispatchEvent(new CustomEvent('copilotkit-error', {
|
||||||
|
detail: {
|
||||||
|
error: e,
|
||||||
|
errorMessage,
|
||||||
|
isFatal: isFatalError,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CopilotKitHealthProvider initialHealthStatus={true}>
|
||||||
|
<CopilotKitDegradedBanner />
|
||||||
|
<ErrorBoundary
|
||||||
|
context="CopilotKit"
|
||||||
|
showDetails={process.env.NODE_ENV === 'development'}
|
||||||
|
fallback={
|
||||||
|
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" color="warning" gutterBottom>
|
||||||
|
Chat Unavailable
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
CopilotKit encountered an error. The app continues to work with manual controls.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CopilotKit
|
||||||
|
publicApiKey={apiKey}
|
||||||
|
showDevConsole={false}
|
||||||
|
onError={handleCopilotKitError}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CopilotKit>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</CopilotKitHealthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CopilotKitHealthProvider initialHealthStatus={false}>
|
||||||
|
<CopilotKitDegradedBanner />
|
||||||
|
{children}
|
||||||
|
</CopilotKitHealthProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component to handle initial routing based on subscription and onboarding status
|
||||||
|
// Flow: Subscription → Onboarding → Dashboard
|
||||||
|
const InitialRouteHandler: React.FC = () => {
|
||||||
|
const { loading, error, isOnboardingComplete, initializeOnboarding, data } = useOnboarding();
|
||||||
|
const { subscription, loading: subscriptionLoading, checkSubscription } = useSubscription();
|
||||||
|
const [connectionError, setConnectionError] = useState<{
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}>({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll for OAuth token alerts and show toast notifications
|
||||||
|
// Only enabled when user is authenticated (has subscription)
|
||||||
|
useOAuthTokenAlerts({
|
||||||
|
enabled: subscription?.active === true,
|
||||||
|
interval: 60000, // Poll every 1 minute
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check subscription on mount (non-blocking - don't wait for it to route)
|
||||||
|
useEffect(() => {
|
||||||
|
// Delay subscription check slightly to allow auth token getter to be installed first
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
// Retry logic for initial subscription check
|
||||||
|
const maxRetries = 3;
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await checkSubscription();
|
||||||
|
break; // Success
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`App: Subscription check attempt ${attempt + 1} failed:`, err);
|
||||||
|
|
||||||
|
// If it's a connection error and we have retries left, wait and retry
|
||||||
|
const isConnectionError = err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError');
|
||||||
|
|
||||||
|
if (isConnectionError && attempt < maxRetries - 1) {
|
||||||
|
const delay = 1000 * Math.pow(2, attempt); // 1s, 2s
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If final attempt or not a connection error, handle it
|
||||||
|
if (attempt === maxRetries - 1 || !isConnectionError) {
|
||||||
|
if (isConnectionError) {
|
||||||
|
setConnectionError({
|
||||||
|
hasError: true,
|
||||||
|
error: err as Error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Don't block routing on other errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100); // Small delay to ensure TokenInstaller has run
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, []); // Remove checkSubscription dependency to prevent loop
|
||||||
|
|
||||||
|
// Initialize onboarding only after subscription is confirmed
|
||||||
|
useEffect(() => {
|
||||||
|
if (subscription && !subscriptionLoading) {
|
||||||
|
// Check if user is new (no subscription record at all)
|
||||||
|
const isNewUser = !subscription || subscription.plan === 'none';
|
||||||
|
|
||||||
|
console.log('InitialRouteHandler: Subscription data received:', {
|
||||||
|
plan: subscription.plan,
|
||||||
|
active: subscription.active,
|
||||||
|
isNewUser,
|
||||||
|
subscriptionLoading
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscription.active && !isNewUser) {
|
||||||
|
console.log('InitialRouteHandler: Subscription confirmed, initializing onboarding...');
|
||||||
|
initializeOnboarding();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [subscription, subscriptionLoading, initializeOnboarding]);
|
||||||
|
|
||||||
|
// Handle connection error - show connection error page
|
||||||
|
if (connectionError.hasError) {
|
||||||
|
const handleRetry = () => {
|
||||||
|
setConnectionError({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
// Re-trigger the subscription check using context
|
||||||
|
checkSubscription().catch((err) => {
|
||||||
|
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
|
||||||
|
setConnectionError({
|
||||||
|
hasError: true,
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoHome = () => {
|
||||||
|
window.location.href = '/';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConnectionErrorPage
|
||||||
|
onRetry={handleRetry}
|
||||||
|
onGoHome={handleGoHome}
|
||||||
|
message={connectionError.error?.message || "Backend service is not available. Please check if the server is running."}
|
||||||
|
title="Connection Error"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state - only wait for onboarding init, not subscription check
|
||||||
|
// Subscription check is non-blocking and happens in background
|
||||||
|
const waitingForOnboardingInit = loading || !data;
|
||||||
|
if (loading || waitingForOnboardingInit) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
minHeight="100vh"
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
<Typography variant="h6" color="textSecondary">
|
||||||
|
{subscriptionLoading ? 'Checking subscription...' : 'Preparing your workspace...'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
minHeight="100vh"
|
||||||
|
gap={2}
|
||||||
|
p={3}
|
||||||
|
>
|
||||||
|
<Typography variant="h5" color="error" gutterBottom>
|
||||||
|
Error
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="textSecondary" textAlign="center">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision tree for SIGNED-IN users:
|
||||||
|
// Priority: Subscription → Onboarding → Dashboard (as per user flow: Landing → Subscription → Onboarding → Dashboard)
|
||||||
|
|
||||||
|
// 1. If subscription is still loading, show loading state
|
||||||
|
if (subscriptionLoading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
minHeight="100vh"
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
<Typography variant="h6" color="textSecondary">
|
||||||
|
Checking subscription...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. No subscription data yet - handle gracefully
|
||||||
|
// If onboarding is complete, allow access to dashboard (user already went through flow)
|
||||||
|
// If onboarding not complete, check if subscription check is still loading or failed
|
||||||
|
if (!subscription) {
|
||||||
|
if (isOnboardingComplete) {
|
||||||
|
console.log('InitialRouteHandler: Onboarding complete but no subscription data → Dashboard (allow access)');
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Onboarding not complete and no subscription data
|
||||||
|
// If subscription check is still loading, show loading state
|
||||||
|
if (subscriptionLoading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
minHeight="100vh"
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
<Typography variant="h6" color="textSecondary">
|
||||||
|
Checking subscription...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription check completed but returned null/undefined
|
||||||
|
// This likely means no subscription - redirect to pricing
|
||||||
|
console.log('InitialRouteHandler: No subscription data after check → Pricing page');
|
||||||
|
return <Navigate to="/pricing" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check subscription status first
|
||||||
|
const isNewUser = !subscription || subscription.plan === 'none';
|
||||||
|
|
||||||
|
// No active subscription → Show modal (SubscriptionContext handles this)
|
||||||
|
// Don't redirect immediately - let the modal show first
|
||||||
|
// User can click "Renew Subscription" button in modal to go to pricing
|
||||||
|
// Or click "Maybe Later" to dismiss (but they still can't use features)
|
||||||
|
if (isNewUser || !subscription.active) {
|
||||||
|
console.log('InitialRouteHandler: No active subscription - modal will be shown by SubscriptionContext');
|
||||||
|
// Note: SubscriptionContext will show the modal automatically when subscription is inactive
|
||||||
|
// We still redirect to pricing for new users, but allow existing users with expired subscriptions
|
||||||
|
// to see the modal first. The modal has a "Renew Subscription" button that navigates to pricing.
|
||||||
|
// For new users (no subscription at all), redirect to pricing immediately
|
||||||
|
if (isNewUser) {
|
||||||
|
console.log('InitialRouteHandler: New user (no subscription) → Pricing page');
|
||||||
|
return <Navigate to="/pricing" replace />;
|
||||||
|
}
|
||||||
|
// For existing users with inactive subscription, show modal but don't redirect immediately
|
||||||
|
// The modal will be shown by SubscriptionContext, and user can click "Renew Subscription"
|
||||||
|
// Allow access to dashboard (modal will be shown and block functionality)
|
||||||
|
console.log('InitialRouteHandler: Inactive subscription - allowing access to show modal');
|
||||||
|
// Continue to onboarding/dashboard flow - modal will be shown by SubscriptionContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Has active subscription, check onboarding status
|
||||||
|
if (!isOnboardingComplete) {
|
||||||
|
console.log('InitialRouteHandler: Subscription active but onboarding incomplete → Onboarding');
|
||||||
|
return <Navigate to="/onboarding" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Has subscription AND completed onboarding → Dashboard
|
||||||
|
console.log('InitialRouteHandler: All set (subscription + onboarding) → Dashboard');
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Root route that chooses Landing (signed out) or InitialRouteHandler (signed in)
|
||||||
|
const RootRoute: React.FC = () => {
|
||||||
|
const { isSignedIn } = useAuth();
|
||||||
|
if (isSignedIn) {
|
||||||
|
return <InitialRouteHandler />;
|
||||||
|
}
|
||||||
|
return <Landing />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Installs Clerk auth token getter into axios clients and stores user_id
|
||||||
|
// Must render under ClerkProvider
|
||||||
|
const TokenInstaller: React.FC = () => {
|
||||||
|
const { getToken, userId, isSignedIn, signOut } = useAuth();
|
||||||
|
|
||||||
|
// Store user_id in localStorage when user signs in
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSignedIn && userId) {
|
||||||
|
console.log('TokenInstaller: Storing user_id in localStorage:', userId);
|
||||||
|
localStorage.setItem('user_id', userId);
|
||||||
|
|
||||||
|
// Trigger event to notify SubscriptionContext that user is authenticated
|
||||||
|
window.dispatchEvent(new CustomEvent('user-authenticated', { detail: { userId } }));
|
||||||
|
} else if (!isSignedIn) {
|
||||||
|
// Clear user_id when signed out
|
||||||
|
console.log('TokenInstaller: Clearing user_id from localStorage');
|
||||||
|
localStorage.removeItem('user_id');
|
||||||
|
}
|
||||||
|
}, [isSignedIn, userId]);
|
||||||
|
|
||||||
|
// Install token getter for API calls
|
||||||
|
useEffect(() => {
|
||||||
|
const tokenGetter = async () => {
|
||||||
|
try {
|
||||||
|
const template = process.env.REACT_APP_CLERK_JWT_TEMPLATE;
|
||||||
|
// If a template is provided and it's not a placeholder, request a template-specific JWT
|
||||||
|
if (template && template !== 'your_jwt_template_name_here') {
|
||||||
|
// @ts-ignore Clerk types allow options object
|
||||||
|
return await getToken({ template });
|
||||||
|
}
|
||||||
|
return await getToken();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set token getter for main API client
|
||||||
|
setAuthTokenGetter(tokenGetter);
|
||||||
|
|
||||||
|
// Set token getter for billing API client (same function)
|
||||||
|
setBillingAuthTokenGetter(tokenGetter);
|
||||||
|
|
||||||
|
// Set token getter for media blob URL fetcher (for authenticated image/video requests)
|
||||||
|
setMediaAuthTokenGetter(tokenGetter);
|
||||||
|
}, [getToken]);
|
||||||
|
|
||||||
|
// Install Clerk signOut function for handling expired tokens
|
||||||
|
useEffect(() => {
|
||||||
|
if (signOut) {
|
||||||
|
setClerkSignOut(async () => {
|
||||||
|
await signOut();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [signOut]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
// React Hooks MUST be at the top before any conditionals
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Get CopilotKit key from localStorage or .env
|
||||||
|
const [copilotApiKey, setCopilotApiKey] = useState(() => {
|
||||||
|
const savedKey = localStorage.getItem('copilotkit_api_key');
|
||||||
|
const envKey = process.env.REACT_APP_COPILOTKIT_API_KEY || '';
|
||||||
|
const key = (savedKey || envKey).trim();
|
||||||
|
|
||||||
|
// Validate key format if present
|
||||||
|
if (key && !key.startsWith('ck_pub_')) {
|
||||||
|
console.warn('CopilotKit API key format invalid - must start with ck_pub_');
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize app - loading state will be managed by InitialRouteHandler
|
||||||
|
useEffect(() => {
|
||||||
|
// Remove manual health check - connection errors are handled by ErrorBoundary
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen for CopilotKit key updates
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyUpdate = (event: CustomEvent) => {
|
||||||
|
const newKey = event.detail?.apiKey;
|
||||||
|
if (newKey) {
|
||||||
|
console.log('App: CopilotKit key updated, reloading...');
|
||||||
|
setCopilotApiKey(newKey);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('copilotkit-key-updated', handleKeyUpdate as EventListener);
|
||||||
|
return () => window.removeEventListener('copilotkit-key-updated', handleKeyUpdate as EventListener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Token installer must be inside ClerkProvider; see TokenInstaller below
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
minHeight="100vh"
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
<Typography variant="h6" color="textSecondary">
|
||||||
|
Connecting to ALwrity...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Get environment variables with fallbacks
|
||||||
|
const clerkPublishableKey = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY || '';
|
||||||
|
const clerkJSUrl = process.env.REACT_APP_CLERK_JS_URL;
|
||||||
|
|
||||||
|
// Show error if required keys are missing
|
||||||
|
if (!clerkPublishableKey) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography color="error" variant="h6">
|
||||||
|
Missing Clerk Publishable Key
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||||
|
Please add REACT_APP_CLERK_PUBLISHABLE_KEY to your .env file
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render app with or without CopilotKit based on whether we have a key
|
||||||
|
const renderApp = () => {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<AuthenticatedCopilotWrapper apiKey={copilotApiKey}>
|
||||||
|
<ConditionalCopilotKit>
|
||||||
|
<TokenInstaller />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<RootRoute />} />
|
||||||
|
<Route
|
||||||
|
path="/onboarding"
|
||||||
|
element={
|
||||||
|
<ErrorBoundary context="Onboarding Wizard" showDetails>
|
||||||
|
<Wizard />
|
||||||
|
</ErrorBoundary>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Error Boundary Testing - Development Only */}
|
||||||
|
{process.env.NODE_ENV === 'development' && (
|
||||||
|
<Route path="/error-test" element={<ErrorBoundaryTest />} />
|
||||||
|
)}
|
||||||
|
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
|
||||||
|
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
|
||||||
|
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
|
||||||
|
<Route path="/story-writer" element={<ProtectedRoute><StoryWriter /></ProtectedRoute>} />
|
||||||
|
<Route path="/story-projects" element={<ProtectedRoute><StoryProjectList /></ProtectedRoute>} />
|
||||||
|
<Route path="/youtube-creator" element={<ProtectedRoute><YouTubeCreator /></ProtectedRoute>} />
|
||||||
|
<Route path="/podcast-maker" element={<ProtectedRoute><PodcastDashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/image-studio" element={<ProtectedRoute><ImageStudioDashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio" element={<ProtectedRoute><VideoStudioDashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio/create" element={<ProtectedRoute><CreateVideo /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio/avatar" element={<ProtectedRoute><AvatarVideo /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio/enhance" element={<ProtectedRoute><EnhanceVideo /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio/extend" element={<ProtectedRoute><ExtendVideo /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio/edit" element={<ProtectedRoute><EditVideo /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio/transform" element={<ProtectedRoute><TransformVideo /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio/social" element={<ProtectedRoute><SocialVideo /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio/face-swap" element={<ProtectedRoute><FaceSwap /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio/video-translate" element={<ProtectedRoute><VideoTranslate /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio/video-background-remover" element={<ProtectedRoute><VideoBackgroundRemover /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio/add-audio-to-video" element={<ProtectedRoute><AddAudioToVideo /></ProtectedRoute>} />
|
||||||
|
<Route path="/video-studio/library" element={<ProtectedRoute><LibraryVideo /></ProtectedRoute>} />
|
||||||
|
<Route path="/image-generator" element={<ProtectedRoute><CreateStudio /></ProtectedRoute>} />
|
||||||
|
<Route path="/image-editor" element={<ProtectedRoute><EditStudio /></ProtectedRoute>} />
|
||||||
|
<Route path="/image-upscale" element={<ProtectedRoute><UpscaleStudio /></ProtectedRoute>} />
|
||||||
|
<Route path="/image-control" element={<ProtectedRoute><ControlStudio /></ProtectedRoute>} />
|
||||||
|
<Route path="/image-studio/face-swap" element={<ProtectedRoute><FaceSwapStudio /></ProtectedRoute>} />
|
||||||
|
<Route path="/image-studio/compress" element={<ProtectedRoute><CompressionStudio /></ProtectedRoute>} />
|
||||||
|
<Route path="/image-studio/processing" element={<ProtectedRoute><ImageProcessingStudio /></ProtectedRoute>} />
|
||||||
|
<Route path="/image-studio/social-optimizer" element={<ProtectedRoute><SocialOptimizer /></ProtectedRoute>} />
|
||||||
|
<Route path="/asset-library" element={<ProtectedRoute><AssetLibrary /></ProtectedRoute>} />
|
||||||
|
<Route path="/campaign-creator" element={<ProtectedRoute><ProductMarketingDashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/campaign-creator/photoshoot" element={<ProtectedRoute><ProductPhotoshootStudio /></ProtectedRoute>} />
|
||||||
|
<Route path="/campaign-creator/animation" element={<ProtectedRoute><ProductAnimationStudio /></ProtectedRoute>} />
|
||||||
|
<Route path="/campaign-creator/video" element={<ProtectedRoute><ProductVideoStudio /></ProtectedRoute>} />
|
||||||
|
<Route path="/campaign-creator/avatar" element={<ProtectedRoute><ProductAvatarStudio /></ProtectedRoute>} />
|
||||||
|
<Route path="/product-marketing" element={<Navigate to="/campaign-creator" replace />} />
|
||||||
|
<Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/approvals" element={<ProtectedRoute><ApprovalsPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/team-activity" element={<ProtectedRoute><TeamActivityPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/stripe-disputes" element={<ProtectedRoute><StripeDisputesDashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/pricing" element={<PricingPage />} />
|
||||||
|
<Route path="/research-test" element={<ResearchDashboard />} />
|
||||||
|
<Route path="/research-dashboard" element={<ResearchDashboard />} />
|
||||||
|
<Route path="/alwrity-researcher" element={<ResearchDashboard />} />
|
||||||
|
<Route path="/intent-research" element={<IntentResearchTest />} />
|
||||||
|
<Route path="/wix-test" element={<WixTestPage />} />
|
||||||
|
<Route path="/wix-test-direct" element={<WixTestPage />} />
|
||||||
|
<Route path="/wix/callback" element={<WixCallbackPage />} />
|
||||||
|
<Route path="/wp/callback" element={<WordPressCallbackPage />} />
|
||||||
|
<Route path="/gsc/callback" element={<GSCAuthCallback />} />
|
||||||
|
<Route path="/bing/callback" element={<BingCallbackPage />} />
|
||||||
|
<Route path="/bing-analytics-storage" element={<ProtectedRoute><BingAnalyticsStorage /></ProtectedRoute>} />
|
||||||
|
</Routes>
|
||||||
|
</ConditionalCopilotKit>
|
||||||
|
</AuthenticatedCopilotWrapper>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
context="Application Root"
|
||||||
|
showDetails={process.env.NODE_ENV === 'development'}
|
||||||
|
onError={(error, errorInfo) => {
|
||||||
|
// Custom error handler - send to analytics/monitoring
|
||||||
|
console.error('Global error caught:', { error, errorInfo });
|
||||||
|
// TODO: Send to error tracking service (Sentry, LogRocket, etc.)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClerkProvider publishableKey={clerkPublishableKey} clerkJSUrl={clerkJSUrl}>
|
||||||
|
<SubscriptionProvider>
|
||||||
|
<OnboardingProvider>
|
||||||
|
{renderApp()}
|
||||||
|
</OnboardingProvider>
|
||||||
|
</SubscriptionProvider>
|
||||||
|
</ClerkProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
537
_session_backup/ResearchSummary.tsx
Normal file
537
_session_backup/ResearchSummary.tsx
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
import React, { useMemo, useCallback } from "react";
|
||||||
|
import { Stack, Typography, Chip, Divider, Box, alpha, Paper, Tooltip } from "@mui/material";
|
||||||
|
import {
|
||||||
|
Insights as InsightsIcon,
|
||||||
|
Search as SearchIcon,
|
||||||
|
AttachMoney as AttachMoneyIcon,
|
||||||
|
EditNote as EditNoteIcon,
|
||||||
|
Article as ArticleIcon,
|
||||||
|
AutoAwesome as AutoAwesomeIcon,
|
||||||
|
FormatQuote as FormatQuoteIcon,
|
||||||
|
Campaign as CampaignIcon,
|
||||||
|
Explore as ExploreIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { Research, ResearchInsight } 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,
|
||||||
|
}) => {
|
||||||
|
// Simple markdown-to-HTML converter
|
||||||
|
const renderMarkdown = useCallback((text: string) => {
|
||||||
|
if (!text) return null;
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.filter(line => line.trim() !== '') // Remove empty lines
|
||||||
|
.map((line, i) => {
|
||||||
|
// Handle bold
|
||||||
|
let processedLine = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
// Handle lists
|
||||||
|
if (processedLine.trim().startsWith('- ') || processedLine.trim().startsWith('* ')) {
|
||||||
|
return <li key={i} dangerouslySetInnerHTML={{ __html: processedLine.trim().substring(2) }} style={{ marginBottom: '4px', fontSize: '0.9rem' }} />;
|
||||||
|
}
|
||||||
|
// Handle headers - make them smaller
|
||||||
|
if (processedLine.startsWith('### ')) {
|
||||||
|
return <Typography key={i} variant="subtitle2" fontWeight={700} sx={{ mt: 1.5, mb: 0.5, color: '#1e293b' }}>{processedLine.substring(4)}</Typography>;
|
||||||
|
}
|
||||||
|
if (processedLine.startsWith('## ')) {
|
||||||
|
return <Typography key={i} variant="subtitle1" fontWeight={700} sx={{ mt: 1.5, mb: 0.5, color: '#0f172a' }}>{processedLine.substring(3)}</Typography>;
|
||||||
|
}
|
||||||
|
// Paragraphs - compact spacing
|
||||||
|
return processedLine.trim() ? <p key={i} dangerouslySetInnerHTML={{ __html: processedLine }} style={{ margin: '4px 0', fontSize: '0.9rem' }} /> : null;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassyCard sx={glassyCardSx}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={2} sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||||
|
<InsightsIcon />
|
||||||
|
Research Summary
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Research Metadata - Moved alongside title */}
|
||||||
|
<Stack direction="row" spacing={1.5} flexWrap="wrap">
|
||||||
|
{research.searchQueries && research.searchQueries.length > 0 && (
|
||||||
|
<Chip
|
||||||
|
icon={<SearchIcon sx={{ fontSize: "1rem !important" }} />}
|
||||||
|
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>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={onGenerateScript}
|
||||||
|
disabled={!canGenerateScript}
|
||||||
|
startIcon={<EditNoteIcon />}
|
||||||
|
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
|
||||||
|
>
|
||||||
|
Generate Script
|
||||||
|
</PrimaryButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
{/* Main Summary */}
|
||||||
|
{research.summary && (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
mb: 3,
|
||||||
|
background: "#f8fafc",
|
||||||
|
border: "1px solid rgba(0,0,0,0.06)",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5, color: "#64748b", fontWeight: 700, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em", display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1rem" }} />
|
||||||
|
Executive Summary
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{
|
||||||
|
lineHeight: 1.6,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "#334155",
|
||||||
|
"& p": { m: 0, mb: 1 },
|
||||||
|
"& ul": { m: 0, mb: 1, pl: 2.5 },
|
||||||
|
"& li": { mb: 0.5 },
|
||||||
|
"& strong": { color: "#0f172a", fontWeight: 600 }
|
||||||
|
}}>
|
||||||
|
{renderMarkdown(research.summary)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deep Insights */}
|
||||||
|
{(research.keyInsights && research.keyInsights.length > 0) ? (
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<ArticleIcon sx={{ color: "#667eea" }} />
|
||||||
|
Deep Insights
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{research.keyInsights.map((insight: ResearchInsight, idx: number) => (
|
||||||
|
<Paper
|
||||||
|
key={idx}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
background: "#ffffff",
|
||||||
|
border: "1px solid rgba(0,0,0,0.06)",
|
||||||
|
boxShadow: "0 2px 12px rgba(0,0,0,0.03)",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700 }}>
|
||||||
|
{insight.title}
|
||||||
|
</Typography>
|
||||||
|
{insight.source_indices && insight.source_indices.length > 0 && (
|
||||||
|
<Stack direction="row" spacing={0.5}>
|
||||||
|
{insight.source_indices.map(sIdx => {
|
||||||
|
const sourceIdx = sIdx - 1;
|
||||||
|
const fact = research.factCards[sourceIdx];
|
||||||
|
const sourceUrl = fact?.url;
|
||||||
|
const hasUrl = !!sourceUrl;
|
||||||
|
const hue = (sIdx * 47 + 220) % 360;
|
||||||
|
const gradientFrom = `hsl(${hue}, 70%, 55%)`;
|
||||||
|
const gradientTo = `hsl(${(hue + 30) % 360}, 80%, 65%)`;
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={sIdx}
|
||||||
|
title={hasUrl ? (
|
||||||
|
<Box sx={{ maxWidth: 300, wordBreak: "break-all" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "#fff", fontWeight: 600 }}>Source {sIdx}</Typography>
|
||||||
|
<br />
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.8)", fontSize: "0.65rem" }}>{sourceUrl}</Typography>
|
||||||
|
</Box>
|
||||||
|
) : `Source ${sIdx}`}
|
||||||
|
arrow
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
label={hasUrl ? `S${sIdx} ↗` : `S${sIdx}`}
|
||||||
|
size="small"
|
||||||
|
onClick={hasUrl ? () => window.open(sourceUrl, "_blank", "noopener,noreferrer") : undefined}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
minWidth: 36,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontFamily: "'Inter', 'Roboto', monospace",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
border: "none",
|
||||||
|
background: hasUrl
|
||||||
|
? `linear-gradient(135deg, ${gradientFrom}, ${gradientTo})`
|
||||||
|
: `linear-gradient(135deg, ${alpha(gradientFrom, 0.3)}, ${alpha(gradientTo, 0.3)})`,
|
||||||
|
color: hasUrl ? "#fff" : alpha("#fff", 0.7),
|
||||||
|
cursor: hasUrl ? "pointer" : "default",
|
||||||
|
borderRadius: "8px",
|
||||||
|
px: 0.5,
|
||||||
|
boxShadow: hasUrl
|
||||||
|
? `0 2px 8px ${alpha(gradientFrom, 0.35)}, inset 0 1px 0 ${alpha("#fff", 0.2)}`
|
||||||
|
: "none",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
"&:hover": hasUrl ? {
|
||||||
|
background: `linear-gradient(135deg, ${gradientTo}, ${gradientFrom})`,
|
||||||
|
boxShadow: `0 4px 14px ${alpha(gradientFrom, 0.5)}, inset 0 1px 0 ${alpha("#fff", 0.3)}`,
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
} : {},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Box sx={{
|
||||||
|
color: "#475569",
|
||||||
|
lineHeight: 1.7,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
"& p": { m: 0, mb: 1.5 },
|
||||||
|
"& ul": { m: 0, mb: 1.5, pl: 2 }
|
||||||
|
}}>
|
||||||
|
{renderMarkdown(insight.content)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
/* Fallback if keyInsights is missing but we have summary paragraphs */
|
||||||
|
research.summary && research.summary.length > 500 && !research.keyInsights && (
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<ArticleIcon sx={{ color: "#667eea" }} />
|
||||||
|
Additional Insights
|
||||||
|
</Typography>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
background: "#ffffff",
|
||||||
|
border: "1px solid rgba(0,0,0,0.06)",
|
||||||
|
boxShadow: "0 2px 12px rgba(0,0,0,0.03)",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{
|
||||||
|
color: "#475569",
|
||||||
|
lineHeight: 1.7,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}>
|
||||||
|
{/* Render parts of summary that might contain insights if structured data is missing */}
|
||||||
|
{renderMarkdown(research.summary.split('\n\n').slice(1).join('\n\n'))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expert Quotes Section */}
|
||||||
|
{research.expertQuotes && research.expertQuotes.length > 0 && (
|
||||||
|
<Box sx={{ mt: 4, pt: 3, borderTop: "1px solid rgba(0,0,0,0.04)" }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<FormatQuoteIcon sx={{ color: "#8b5cf6" }} />
|
||||||
|
Expert Quotes ({research.expertQuotes.length})
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{research.expertQuotes.map((eq, idx) => (
|
||||||
|
<Paper
|
||||||
|
key={idx}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
background: "linear-gradient(135deg, rgba(139, 92, 246, 0.04) 0%, rgba(99, 102, 241, 0.04) 100%)",
|
||||||
|
border: "1px solid rgba(139, 92, 246, 0.15)",
|
||||||
|
borderLeft: "4px solid #8b5cf6",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||||
|
<FormatQuoteIcon sx={{ color: "#8b5cf6", fontSize: "1.5rem", mt: -0.5, opacity: 0.7 }} />
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#1e293b", fontStyle: "italic", lineHeight: 1.7, fontSize: "0.95rem" }}>
|
||||||
|
“{eq.quote}”
|
||||||
|
</Typography>
|
||||||
|
{eq.source_index !== undefined && (() => {
|
||||||
|
const fact = research.factCards[eq.source_index - 1];
|
||||||
|
const sourceUrl = fact?.url;
|
||||||
|
const hasUrl = !!sourceUrl;
|
||||||
|
const hue = (eq.source_index * 47 + 270) % 360;
|
||||||
|
const gradientFrom = `hsl(${hue}, 70%, 55%)`;
|
||||||
|
const gradientTo = `hsl(${(hue + 30) % 360}, 80%, 65%)`;
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<Tooltip title={hasUrl ? (
|
||||||
|
<Box sx={{ maxWidth: 300, wordBreak: "break-all" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "#fff", fontWeight: 600 }}>Source {eq.source_index}</Typography>
|
||||||
|
<br />
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.8)", fontSize: "0.65rem" }}>{sourceUrl}</Typography>
|
||||||
|
</Box>
|
||||||
|
) : `Source ${eq.source_index}`} arrow placement="top">
|
||||||
|
<Chip
|
||||||
|
label={hasUrl ? `Source ${eq.source_index} ↗` : `Source ${eq.source_index}`}
|
||||||
|
size="small"
|
||||||
|
onClick={hasUrl ? () => window.open(sourceUrl, "_blank", "noopener,noreferrer") : undefined}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontFamily: "'Inter', 'Roboto', monospace",
|
||||||
|
border: "none",
|
||||||
|
background: hasUrl
|
||||||
|
? `linear-gradient(135deg, ${gradientFrom}, ${gradientTo})`
|
||||||
|
: `linear-gradient(135deg, ${alpha(gradientFrom, 0.3)}, ${alpha(gradientTo, 0.3)})`,
|
||||||
|
color: hasUrl ? "#fff" : alpha("#fff", 0.7),
|
||||||
|
cursor: hasUrl ? "pointer" : "default",
|
||||||
|
borderRadius: "8px",
|
||||||
|
px: 1,
|
||||||
|
boxShadow: hasUrl
|
||||||
|
? `0 2px 8px ${alpha(gradientFrom, 0.35)}, inset 0 1px 0 ${alpha("#fff", 0.2)}`
|
||||||
|
: "none",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
"&:hover": hasUrl ? {
|
||||||
|
background: `linear-gradient(135deg, ${gradientTo}, ${gradientFrom})`,
|
||||||
|
boxShadow: `0 4px 14px ${alpha(gradientFrom, 0.5)}, inset 0 1px 0 ${alpha("#fff", 0.3)}`,
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
} : {},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Queries Used */}
|
||||||
|
{research.searchQueries && research.searchQueries.length > 0 && (
|
||||||
|
<Box sx={{ mt: 4, pt: 3, borderTop: "1px solid rgba(0,0,0,0.04)" }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5, color: "#64748b", fontWeight: 700, fontSize: "0.7rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||||
|
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.15)",
|
||||||
|
color: "#94a3b8",
|
||||||
|
background: alpha("#f8fafc", 0.3),
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{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, flexWrap: "wrap", gap: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600 }}>
|
||||||
|
Research Sources & Facts ({research.factCards.length})
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.75rem" }}>
|
||||||
|
Click to expand • Hover to see source
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", md: "repeat(3, 1fr)", lg: "repeat(4, 1fr)" },
|
||||||
|
gap: 1.5,
|
||||||
|
width: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{research.factCards.map((fact) => (
|
||||||
|
<FactCard key={fact.id} fact={fact} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Listener CTA Section */}
|
||||||
|
{research.listenerCta && research.listenerCta.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<CampaignIcon sx={{ color: "#f59e0b" }} />
|
||||||
|
Listener Call-to-Action Ideas ({research.listenerCta.length})
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
{research.listenerCta.map((cta, idx) => (
|
||||||
|
<Paper
|
||||||
|
key={idx}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
background: "linear-gradient(135deg, rgba(245, 158, 11, 0.05) 0%, rgba(251, 191, 36, 0.05) 100%)",
|
||||||
|
border: "1px solid rgba(245, 158, 11, 0.15)",
|
||||||
|
borderRadius: 2,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
label={`#${idx + 1}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: alpha("#f59e0b", 0.15),
|
||||||
|
color: "#b45309",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
height: 24,
|
||||||
|
minWidth: 32,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.6, flex: 1, pt: 0.2 }}>
|
||||||
|
{cta}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mapped Angles Section */}
|
||||||
|
{research.mappedAngles && research.mappedAngles.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<ExploreIcon sx={{ color: "#06b6d4" }} />
|
||||||
|
Content Angles ({research.mappedAngles.length})
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{research.mappedAngles.map((angle, idx) => (
|
||||||
|
<Paper
|
||||||
|
key={idx}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
background: "#ffffff",
|
||||||
|
border: "1px solid rgba(0,0,0,0.06)",
|
||||||
|
borderLeft: "4px solid #06b6d4",
|
||||||
|
boxShadow: "0 2px 12px rgba(0,0,0,0.03)",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700 }}>
|
||||||
|
{angle.title}
|
||||||
|
</Typography>
|
||||||
|
{angle.mappedFactIds && angle.mappedFactIds.length > 0 && (
|
||||||
|
<Stack direction="row" spacing={0.5}>
|
||||||
|
{angle.mappedFactIds.slice(0, 4).map((fid: string) => (
|
||||||
|
<Chip
|
||||||
|
key={fid}
|
||||||
|
label={fid.replace("fact_", "F")}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
height: 18,
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
borderColor: alpha("#06b6d4", 0.3),
|
||||||
|
color: "#06b6d4",
|
||||||
|
bgcolor: alpha("#06b6d4", 0.05),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{angle.mappedFactIds.length > 4 && (
|
||||||
|
<Chip
|
||||||
|
label={`+${angle.mappedFactIds.length - 4}`}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 18, fontSize: "0.6rem", color: "#64748b" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7, fontSize: "0.9rem" }}>
|
||||||
|
{angle.why}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</GlassyCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
811
_session_backup/SceneEditor.tsx
Normal file
811
_session_backup/SceneEditor.tsx
Normal file
@@ -0,0 +1,811 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress, IconButton, Tooltip } from "@mui/material";
|
||||||
|
import {
|
||||||
|
EditNote as EditNoteIcon,
|
||||||
|
CheckCircle as CheckCircleIcon,
|
||||||
|
RadioButtonUnchecked as RadioButtonUncheckedIcon,
|
||||||
|
VolumeUp as VolumeUpIcon,
|
||||||
|
PlayArrow as PlayArrowIcon,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { Scene, Line, Knobs } from "../types";
|
||||||
|
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||||
|
import { LineEditor } from "./LineEditor";
|
||||||
|
import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerateModal";
|
||||||
|
import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal";
|
||||||
|
import { podcastApi } from "../../../services/podcastApi";
|
||||||
|
import { aiApiClient } from "../../../api/client";
|
||||||
|
import { getCachedMedia, setCachedMedia } from "../../../utils/mediaCache";
|
||||||
|
|
||||||
|
interface SceneEditorProps {
|
||||||
|
scene: Scene;
|
||||||
|
onUpdateScene: (s: Scene) => void;
|
||||||
|
onApprove: (id: string) => Promise<void>;
|
||||||
|
onDelete: (sceneId: string) => void;
|
||||||
|
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
|
||||||
|
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
|
||||||
|
totalScenes?: number; // Total number of scenes in the script
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||||
|
scene,
|
||||||
|
onUpdateScene,
|
||||||
|
onApprove,
|
||||||
|
onDelete,
|
||||||
|
knobs,
|
||||||
|
approvingSceneId,
|
||||||
|
generatingAudioId,
|
||||||
|
onAudioGenerationStart,
|
||||||
|
onAudioGenerated,
|
||||||
|
idea,
|
||||||
|
avatarUrl,
|
||||||
|
totalScenes,
|
||||||
|
}) => {
|
||||||
|
const [localGenerating, setLocalGenerating] = useState(false);
|
||||||
|
const [generatingImage, setGeneratingImage] = useState(false);
|
||||||
|
const [imageGenerationStatus, setImageGenerationStatus] = useState<string>("");
|
||||||
|
const [imageGenerationProgress, setImageGenerationProgress] = useState<number>(0);
|
||||||
|
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
|
||||||
|
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
|
||||||
|
const [imageLoading, setImageLoading] = useState(false);
|
||||||
|
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
|
||||||
|
const [showAudioModal, setShowAudioModal] = useState(false);
|
||||||
|
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
|
||||||
|
voiceId: "Wise_Woman",
|
||||||
|
speed: 1.0,
|
||||||
|
volume: 1.0,
|
||||||
|
pitch: 0.0,
|
||||||
|
emotion: scene.emotion || "neutral",
|
||||||
|
englishNormalization: true,
|
||||||
|
sampleRate: 24000,
|
||||||
|
bitrate: 64000,
|
||||||
|
channel: "1",
|
||||||
|
format: "mp3",
|
||||||
|
languageBoost: "auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
// Load image as blob when imageUrl is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene.imageUrl) {
|
||||||
|
// Clean up blob URL if imageUrl is removed
|
||||||
|
setImageBlobUrl((currentBlobUrl) => {
|
||||||
|
if (currentBlobUrl && currentBlobUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(currentBlobUrl);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first with scene context
|
||||||
|
const cachedUrl = getCachedMedia(scene.imageUrl, scene.id);
|
||||||
|
if (cachedUrl) {
|
||||||
|
console.log('[SceneEditor] Using cached image:', scene.imageUrl, `(scene: ${scene.id})`);
|
||||||
|
setImageBlobUrl(cachedUrl);
|
||||||
|
setImageLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
const currentImageUrl = scene.imageUrl; // Capture current value
|
||||||
|
|
||||||
|
const loadImageBlob = async () => {
|
||||||
|
try {
|
||||||
|
setImageLoading(true);
|
||||||
|
|
||||||
|
// Check cache again in case it was loaded while we were waiting
|
||||||
|
const cachedUrl = getCachedMedia(currentImageUrl, scene.id);
|
||||||
|
if (cachedUrl) {
|
||||||
|
if (isMounted) {
|
||||||
|
setImageBlobUrl(cachedUrl);
|
||||||
|
setImageLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[SceneEditor] Loading image blob for:', currentImageUrl);
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
|
||||||
|
const response = await aiApiClient.get(imagePath, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-check that imageUrl hasn't changed
|
||||||
|
if (scene.imageUrl !== currentImageUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = response.data;
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Cache the blob URL with scene context
|
||||||
|
setCachedMedia(currentImageUrl, blobUrl, 'image', blob.size, scene.id);
|
||||||
|
|
||||||
|
setImageBlobUrl((prevBlobUrl) => {
|
||||||
|
// Clean up previous blob URL if exists
|
||||||
|
if (prevBlobUrl && prevBlobUrl !== blobUrl && prevBlobUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(prevBlobUrl);
|
||||||
|
}
|
||||||
|
return blobUrl;
|
||||||
|
});
|
||||||
|
console.log('[SceneEditor] Image blob loaded and cached successfully:', currentImageUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SceneEditor] Failed to load image blob:', error);
|
||||||
|
if (isMounted) {
|
||||||
|
// Try adding query token as fallback
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('clerk_dashboard_token') || '';
|
||||||
|
if (token) {
|
||||||
|
const urlWithToken = `${currentImageUrl}?token=${encodeURIComponent(token)}`;
|
||||||
|
setImageBlobUrl(urlWithToken);
|
||||||
|
setCachedMedia(currentImageUrl, urlWithToken, 'image', undefined, scene.id);
|
||||||
|
}
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('[SceneEditor] Fallback image loading failed:', fallbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setImageLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadImageBlob();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
// Don't cleanup blob URL here - let the cache handle it
|
||||||
|
};
|
||||||
|
}, [scene.imageUrl]);
|
||||||
|
|
||||||
|
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 generating = generatingAudioId === scene.id || localGenerating;
|
||||||
|
const hasAudio = Boolean(scene.audioUrl && audioBlobUrl);
|
||||||
|
const hasImage = Boolean(scene.imageUrl);
|
||||||
|
|
||||||
|
const handleApproveAndGenerate = async (settings?: AudioGenerationSettings) => {
|
||||||
|
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 effectiveSettings = settings || audioSettings;
|
||||||
|
const result = await podcastApi.renderSceneAudio({
|
||||||
|
scene: currentScene,
|
||||||
|
voiceId: effectiveSettings.voiceId || "Wise_Woman",
|
||||||
|
emotion: effectiveSettings.emotion || scene.emotion || knobs.voice_emotion || "neutral",
|
||||||
|
speed: effectiveSettings.speed ?? knobs.voice_speed ?? 1.0,
|
||||||
|
volume: effectiveSettings.volume ?? 1.0,
|
||||||
|
pitch: effectiveSettings.pitch ?? 0.0,
|
||||||
|
englishNormalization: effectiveSettings.englishNormalization ?? true,
|
||||||
|
sampleRate: effectiveSettings.sampleRate,
|
||||||
|
bitrate: effectiveSettings.bitrate,
|
||||||
|
channel: effectiveSettings.channel,
|
||||||
|
format: effectiveSettings.format,
|
||||||
|
languageBoost: effectiveSettings.languageBoost,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 (settings?: ImageGenerationSettings) => {
|
||||||
|
const sceneId = scene.id;
|
||||||
|
const startTime = Date.now();
|
||||||
|
let progressInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setGeneratingImage(true);
|
||||||
|
setShowRegenerateModal(false);
|
||||||
|
setImageGenerationStatus("Submitting image generation request...");
|
||||||
|
setImageGenerationProgress(10);
|
||||||
|
|
||||||
|
// Build scene content from lines for context
|
||||||
|
const sceneContent = scene.lines.map((line) => line.text).join(" ");
|
||||||
|
|
||||||
|
// Log avatar URL for debugging
|
||||||
|
console.log("[SceneEditor] Generating image with avatarUrl:", avatarUrl);
|
||||||
|
console.log("[SceneEditor] Custom settings:", settings);
|
||||||
|
|
||||||
|
// Simulate progress updates during API call
|
||||||
|
progressInterval = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const seconds = Math.floor(elapsed / 1000);
|
||||||
|
|
||||||
|
// Update status based on elapsed time
|
||||||
|
if (seconds < 5) {
|
||||||
|
setImageGenerationStatus("Submitting request to AI service...");
|
||||||
|
setImageGenerationProgress(15);
|
||||||
|
} else if (seconds < 15) {
|
||||||
|
setImageGenerationStatus("AI is generating your image...");
|
||||||
|
setImageGenerationProgress(30);
|
||||||
|
} else if (seconds < 30) {
|
||||||
|
setImageGenerationStatus("Creating character-consistent scene image...");
|
||||||
|
setImageGenerationProgress(50);
|
||||||
|
} else if (seconds < 60) {
|
||||||
|
setImageGenerationStatus("Rendering image details...");
|
||||||
|
setImageGenerationProgress(70);
|
||||||
|
} else {
|
||||||
|
setImageGenerationStatus(`Processing... (${seconds}s elapsed)`);
|
||||||
|
setImageGenerationProgress(Math.min(90, 50 + (seconds - 30) / 2));
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
const result = await podcastApi.generateSceneImage({
|
||||||
|
sceneId: scene.id,
|
||||||
|
sceneTitle: scene.title,
|
||||||
|
sceneContent: sceneContent,
|
||||||
|
baseAvatarUrl: avatarUrl || undefined, // Pass base avatar URL for character consistency
|
||||||
|
idea: idea,
|
||||||
|
width: 1024,
|
||||||
|
height: 1024,
|
||||||
|
// Pass custom settings if provided
|
||||||
|
customPrompt: settings?.prompt,
|
||||||
|
style: settings?.style,
|
||||||
|
renderingSpeed: settings?.renderingSpeed,
|
||||||
|
aspectRatio: settings?.aspectRatio,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (progressInterval) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
progressInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageGenerationStatus("Finalizing image...");
|
||||||
|
setImageGenerationProgress(95);
|
||||||
|
|
||||||
|
// Update scene with image URL
|
||||||
|
const updatedScene = { ...scene, imageUrl: result.image_url };
|
||||||
|
onUpdateScene(updatedScene);
|
||||||
|
|
||||||
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
setImageGenerationStatus(`Image generated successfully in ${elapsed}s`);
|
||||||
|
setImageGenerationProgress(100);
|
||||||
|
|
||||||
|
// Clear status after a moment
|
||||||
|
setTimeout(() => {
|
||||||
|
setImageGenerationStatus("");
|
||||||
|
setImageGenerationProgress(0);
|
||||||
|
}, 2000);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Clear interval on error
|
||||||
|
if (progressInterval) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
progressInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Failed to generate image:", error);
|
||||||
|
// Extract error message from response if available
|
||||||
|
const errorMessage = error?.response?.data?.detail?.message
|
||||||
|
|| error?.response?.data?.detail?.error
|
||||||
|
|| error?.response?.data?.detail
|
||||||
|
|| error?.message
|
||||||
|
|| "Failed to generate image. Please try again.";
|
||||||
|
console.error("Error details:", {
|
||||||
|
status: error?.response?.status,
|
||||||
|
statusText: error?.response?.statusText,
|
||||||
|
data: error?.response?.data,
|
||||||
|
message: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
setImageGenerationStatus(`Error: ${errorMessage}`);
|
||||||
|
setImageGenerationProgress(0);
|
||||||
|
|
||||||
|
// Show user-friendly error message
|
||||||
|
alert(`Image generation failed: ${errorMessage}`);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// Ensure interval is cleared
|
||||||
|
if (progressInterval) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
}
|
||||||
|
setGeneratingImage(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerateClick = () => {
|
||||||
|
setShowRegenerateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAudioRegenerateClick = () => {
|
||||||
|
if (hasAudio) {
|
||||||
|
setShowAudioModal(true);
|
||||||
|
} else {
|
||||||
|
handleApproveAndGenerate(audioSettings);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAudioRegenerate = (settings: AudioGenerationSettings) => {
|
||||||
|
setAudioSettings(settings);
|
||||||
|
setShowAudioModal(false);
|
||||||
|
handleApproveAndGenerate(settings);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassyCard sx={glassyCardSx}>
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||||
|
<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.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
|
||||||
|
? "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" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem" }}>
|
||||||
|
Duration: {scene.duration}s
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleAudioRegenerateClick}
|
||||||
|
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 with custom settings"
|
||||||
|
: 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={hasImage ? handleRegenerateClick : () => 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>
|
||||||
|
|
||||||
|
<Tooltip title={totalScenes && totalScenes <= 1 ? "Cannot delete the last scene" : "Delete this scene"}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onDelete(scene.id)}
|
||||||
|
disabled={approving || generating || (totalScenes !== undefined && totalScenes <= 1)}
|
||||||
|
sx={{
|
||||||
|
color: "#ef4444",
|
||||||
|
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||||
|
border: "1px solid rgba(239, 68, 68, 0.2)",
|
||||||
|
borderRadius: 2,
|
||||||
|
padding: 1.5,
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "rgba(239, 68, 68, 0.15)",
|
||||||
|
borderColor: "rgba(239, 68, 68, 0.3)",
|
||||||
|
},
|
||||||
|
"&:disabled": {
|
||||||
|
backgroundColor: "rgba(156, 163, 175, 0.1)",
|
||||||
|
borderColor: "rgba(156, 163, 175, 0.2)",
|
||||||
|
color: "#9ca3af",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon sx={{ fontSize: "1.25rem" }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<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} />
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image Generation Progress - Show when generating */}
|
||||||
|
{generatingImage && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1, mt: 1 }} />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)",
|
||||||
|
borderRadius: 2,
|
||||||
|
border: "1px solid rgba(102, 126, 234, 0.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
|
||||||
|
<ImageIcon sx={{ color: "#667eea", fontSize: "1.25rem" }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#667eea", fontWeight: 600 }}>
|
||||||
|
Generating Image...
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<Box sx={{ mb: 1.5 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={imageGenerationProgress}
|
||||||
|
sx={{
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: alpha("#667eea", 0.1),
|
||||||
|
"& .MuiLinearProgress-bar": {
|
||||||
|
backgroundColor: "#667eea",
|
||||||
|
borderRadius: 4,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: "#667eea", mt: 0.5, display: "block", textAlign: "right" }}>
|
||||||
|
{imageGenerationProgress}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Status Message */}
|
||||||
|
{imageGenerationStatus && (
|
||||||
|
<Typography variant="body2" sx={{ color: "#667eea", fontSize: "0.875rem", lineHeight: 1.6, mb: 1 }}>
|
||||||
|
{imageGenerationStatus}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spinner */}
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", mt: 1 }}>
|
||||||
|
<CircularProgress size={32} sx={{ color: "#667eea" }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Generated Image Display - Show when image exists and not generating */}
|
||||||
|
{scene.imageUrl && !generatingImage && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1, mt: 1 }} />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
background: imageBlobUrl && !imageLoading
|
||||||
|
? "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)"
|
||||||
|
: "linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(217, 119, 6, 0.08) 100%)",
|
||||||
|
borderRadius: 2,
|
||||||
|
border: imageBlobUrl && !imageLoading
|
||||||
|
? "1px solid rgba(102, 126, 234, 0.2)"
|
||||||
|
: "1px solid rgba(245, 158, 11, 0.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
|
||||||
|
<ImageIcon sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontSize: "1.25rem" }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontWeight: 600 }}>
|
||||||
|
{imageBlobUrl && !imageLoading ? "Image Generated" : "Loading Image..."}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{imageBlobUrl && !imageLoading ? (
|
||||||
|
<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}
|
||||||
|
alt={scene.title}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "auto",
|
||||||
|
display: "block",
|
||||||
|
maxHeight: 400,
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('[SceneEditor] Image failed to load:', {
|
||||||
|
src: e.currentTarget.src,
|
||||||
|
imageUrl: scene.imageUrl,
|
||||||
|
imageBlobUrl,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onLoad={() => {
|
||||||
|
console.log('[SceneEditor] Image loaded successfully');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", py: 2 }}>
|
||||||
|
<CircularProgress size={24} sx={{ color: "#d97706" }} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Image Regeneration Modal */}
|
||||||
|
<ImageRegenerateModal
|
||||||
|
open={showRegenerateModal}
|
||||||
|
onClose={() => setShowRegenerateModal(false)}
|
||||||
|
onRegenerate={handleGenerateImage}
|
||||||
|
initialPrompt={(() => {
|
||||||
|
const promptParts = [
|
||||||
|
`Scene: ${scene.title}`,
|
||||||
|
"Professional podcast recording studio",
|
||||||
|
"Modern microphone setup",
|
||||||
|
"Clean background, professional lighting",
|
||||||
|
"16:9 aspect ratio, video-optimized composition"
|
||||||
|
];
|
||||||
|
if (idea) {
|
||||||
|
promptParts.push(`Topic: ${idea.substring(0, 60)}`);
|
||||||
|
}
|
||||||
|
return promptParts.join(", ");
|
||||||
|
})()}
|
||||||
|
initialStyle="Realistic"
|
||||||
|
initialRenderingSpeed="Quality"
|
||||||
|
initialAspectRatio="16:9"
|
||||||
|
isGenerating={generatingImage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AudioRegenerateModal
|
||||||
|
open={showAudioModal}
|
||||||
|
onClose={() => setShowAudioModal(false)}
|
||||||
|
onRegenerate={handleAudioRegenerate}
|
||||||
|
initialSettings={audioSettings}
|
||||||
|
isGenerating={generating}
|
||||||
|
/>
|
||||||
|
</GlassyCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
818
_session_backup/ScriptEditor.tsx
Normal file
818
_session_backup/ScriptEditor.tsx
Normal file
@@ -0,0 +1,818 @@
|
|||||||
|
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;
|
||||||
|
idea: string;
|
||||||
|
research: any; // Research type
|
||||||
|
rawResearch: BlogResearchResponse | null;
|
||||||
|
knobs: Knobs;
|
||||||
|
speakers: number;
|
||||||
|
durationMinutes: number;
|
||||||
|
script: Script | null;
|
||||||
|
onScriptChange: (script: Script) => void;
|
||||||
|
onBackToResearch: () => void;
|
||||||
|
onProceedToRendering: (script: Script) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
|
||||||
|
analysis?: any;
|
||||||
|
outline?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||||
|
projectId,
|
||||||
|
idea,
|
||||||
|
research,
|
||||||
|
rawResearch,
|
||||||
|
knobs,
|
||||||
|
speakers,
|
||||||
|
durationMinutes,
|
||||||
|
script: initialScript,
|
||||||
|
onScriptChange,
|
||||||
|
onBackToResearch,
|
||||||
|
onProceedToRendering,
|
||||||
|
onError,
|
||||||
|
avatarUrl,
|
||||||
|
analysis,
|
||||||
|
outline,
|
||||||
|
}) => {
|
||||||
|
const [script, setScript] = useState<Script | null>(initialScript);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
|
||||||
|
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
|
||||||
|
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(true);
|
||||||
|
const [combiningAudio, setCombiningAudio] = useState(false);
|
||||||
|
const [combinedAudioResult, setCombinedAudioResult] = useState<{
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
duration: number;
|
||||||
|
sceneCount: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Defer upward script updates to avoid setState during render warnings
|
||||||
|
const emitScriptChange = useCallback(
|
||||||
|
(next: Script) => Promise.resolve().then(() => onScriptChange(next)),
|
||||||
|
[onScriptChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync with parent state
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialScript) {
|
||||||
|
setScript(initialScript);
|
||||||
|
}
|
||||||
|
}, [initialScript]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If script already exists, don't regenerate
|
||||||
|
if (script) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only generate if we have research data
|
||||||
|
if (!rawResearch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
podcastApi
|
||||||
|
.generateScript({
|
||||||
|
projectId,
|
||||||
|
idea,
|
||||||
|
research: rawResearch,
|
||||||
|
knobs,
|
||||||
|
speakers,
|
||||||
|
durationMinutes,
|
||||||
|
analysis,
|
||||||
|
outline,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (mounted) {
|
||||||
|
setScript(res);
|
||||||
|
emitScriptChange(res);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to generate script";
|
||||||
|
setError(message);
|
||||||
|
onError(message);
|
||||||
|
})
|
||||||
|
.finally(() => mounted && setLoading(false));
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes, analysis, outline, emitScriptChange, onError, script]);
|
||||||
|
|
||||||
|
const updateScene = (updated: Scene) => {
|
||||||
|
// 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))
|
||||||
|
};
|
||||||
|
emitScriptChange(updatedScript);
|
||||||
|
return updatedScript;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const approveScene = async (sceneId: string) => {
|
||||||
|
try {
|
||||||
|
setApprovingSceneId(sceneId);
|
||||||
|
await podcastApi.approveScene({ projectId, sceneId });
|
||||||
|
// 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)),
|
||||||
|
};
|
||||||
|
emitScriptChange(updatedScript);
|
||||||
|
return updatedScript;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to approve scene";
|
||||||
|
setError(message);
|
||||||
|
onError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setApprovingSceneId((current) => (current === sceneId ? null : current));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteScene = useCallback((sceneId: string) => {
|
||||||
|
if (!script) return;
|
||||||
|
|
||||||
|
// Prevent deleting if it's the last scene
|
||||||
|
if (script.scenes.length <= 1) {
|
||||||
|
onError("Cannot delete the last scene. At least one scene is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add confirmation dialog
|
||||||
|
const sceneToDelete = script.scenes.find(s => s.id === sceneId);
|
||||||
|
if (!sceneToDelete) return;
|
||||||
|
|
||||||
|
const confirmDelete = window.confirm(
|
||||||
|
`Are you sure you want to delete "${sceneToDelete.title}"? This action cannot be undone.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
|
||||||
|
// Remove the scene from the script
|
||||||
|
const updatedScenes = script.scenes.filter(s => s.id !== sceneId);
|
||||||
|
const updatedScript = { ...script, scenes: updatedScenes };
|
||||||
|
|
||||||
|
emitScriptChange(updatedScript);
|
||||||
|
setScript(updatedScript);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
console.log(`[ScriptEditor] Scene "${sceneToDelete.title}" deleted successfully`);
|
||||||
|
}, [script, emitScriptChange, onError]);
|
||||||
|
|
||||||
|
const allApproved = script && script.scenes.every((s) => s.approved);
|
||||||
|
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: 4 }}>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
|
||||||
|
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
|
||||||
|
Back to Research
|
||||||
|
</SecondaryButton>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
sx={{
|
||||||
|
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1.5,
|
||||||
|
fontSize: { xs: "1.75rem", md: "2rem" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditNoteIcon sx={{ fontSize: "2rem" }} />
|
||||||
|
Script Editor
|
||||||
|
</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,
|
||||||
|
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,
|
||||||
|
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}>
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{script.scenes.map((scene, idx) => (
|
||||||
|
<GlassyCard
|
||||||
|
key={scene.id}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: idx * 0.1 }}
|
||||||
|
>
|
||||||
|
<SceneEditor
|
||||||
|
scene={scene}
|
||||||
|
onUpdateScene={updateScene}
|
||||||
|
onApprove={approveScene}
|
||||||
|
onDelete={deleteScene}
|
||||||
|
knobs={knobs}
|
||||||
|
approvingSceneId={approvingSceneId}
|
||||||
|
generatingAudioId={generatingAudioId}
|
||||||
|
totalScenes={script.scenes.length}
|
||||||
|
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 };
|
||||||
|
emitScriptChange(updatedScript);
|
||||||
|
return updatedScript;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
idea={idea}
|
||||||
|
avatarUrl={avatarUrl}
|
||||||
|
/>
|
||||||
|
</GlassyCard>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
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: 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" sx={{ color: "#64748b", fontWeight: 400, lineHeight: 1.6 }}>
|
||||||
|
{approvedCount} of {totalScenes} scenes approved
|
||||||
|
{allScenesHaveAudioAndImages && " • All scenes ready for video rendering"}
|
||||||
|
{!allScenesHaveAudioAndImages && allApproved && " • Generate images for all scenes to enable video rendering"}
|
||||||
|
{!allApproved && " — Approve all scenes first"}
|
||||||
|
</Typography>
|
||||||
|
{!allScenesHaveAudioAndImages && (
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
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={!allScenesHaveAudioAndImages}
|
||||||
|
startIcon={<PlayArrowIcon />}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
334
_session_backup/analysis.py
Normal file
334
_session_backup/analysis.py
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
"""
|
||||||
|
Podcast Analysis Handlers
|
||||||
|
|
||||||
|
Analysis endpoint for podcast ideas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from typing import Dict, Any
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from services.database import get_db
|
||||||
|
from middleware.auth_middleware import get_current_user
|
||||||
|
from api.story_writer.utils.auth import require_authenticated_user
|
||||||
|
from services.llm_providers.main_text_generation import llm_text_gen
|
||||||
|
from services.llm_providers.main_image_generation import generate_image
|
||||||
|
from services.podcast_bible_service import PodcastBibleService
|
||||||
|
from utils.asset_tracker import save_asset_to_library
|
||||||
|
from loguru import logger
|
||||||
|
from ..constants import PODCAST_IMAGES_DIR
|
||||||
|
from ..models import (
|
||||||
|
PodcastAnalyzeRequest,
|
||||||
|
PodcastAnalyzeResponse,
|
||||||
|
PodcastEnhanceIdeaRequest,
|
||||||
|
PodcastEnhanceIdeaResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/idea/enhance", response_model=PodcastEnhanceIdeaResponse)
|
||||||
|
async def enhance_podcast_idea(
|
||||||
|
request: PodcastEnhanceIdeaRequest,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Take raw keywords/topic and use AI to craft a presentable, detailed podcast idea.
|
||||||
|
Uses the user's Podcast Bible for hyper-personalization if available.
|
||||||
|
"""
|
||||||
|
user_id = require_authenticated_user(current_user)
|
||||||
|
|
||||||
|
# Serialize Bible context if provided or generate from onboarding
|
||||||
|
bible_context = ""
|
||||||
|
try:
|
||||||
|
bible_service = PodcastBibleService()
|
||||||
|
if request.bible:
|
||||||
|
from models.podcast_bible_models import PodcastBible
|
||||||
|
bible_data = PodcastBible(**request.bible)
|
||||||
|
bible_context = bible_service.serialize_bible(bible_data)
|
||||||
|
else:
|
||||||
|
# Generate from onboarding data directly
|
||||||
|
bible_obj = bible_service.generate_bible(user_id, "temp_enhance")
|
||||||
|
bible_context = bible_service.serialize_bible(bible_obj)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"[Podcast Enhance] Failed to parse or generate bible context: {exc}")
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
You are a creative podcast producer. Generate 3 distinct, compelling podcast episode concepts from the raw idea.
|
||||||
|
|
||||||
|
{f"USER PERSONALIZATION CONTEXT (Podcast Bible):\n{bible_context}\n" if bible_context else ""}
|
||||||
|
|
||||||
|
RAW IDEA/KEYWORDS: "{request.idea}"
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
Generate 3 different enhanced versions, each with a unique angle:
|
||||||
|
1. Professional & Expert-led angle (focus on authority, insights, and expertise)
|
||||||
|
2. Storytelling & Human interest angle (focus on narratives, emotions, and personal connections)
|
||||||
|
3. Trendy & Contemporary angle (focus on current trends, modern perspectives, and relevance)
|
||||||
|
|
||||||
|
Each version should be 2-3 sentences, audience-focused, and align with host persona if provided.
|
||||||
|
|
||||||
|
Return JSON with:
|
||||||
|
- enhanced_ideas: array of 3 enhanced episode pitches (in order: Professional, Storytelling, Trendy)
|
||||||
|
- rationales: array of 3 rationales explaining the approach for each version
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = llm_text_gen(
|
||||||
|
prompt=prompt,
|
||||||
|
user_id=user_id,
|
||||||
|
json_struct=None,
|
||||||
|
preferred_provider="huggingface",
|
||||||
|
flow_type="premium_tool",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalize response
|
||||||
|
if isinstance(raw, str):
|
||||||
|
data = json.loads(raw)
|
||||||
|
else:
|
||||||
|
data = raw
|
||||||
|
|
||||||
|
# Extract enhanced ideas and rationales with fallbacks
|
||||||
|
enhanced_ideas = data.get("enhanced_ideas", [])
|
||||||
|
rationales = data.get("rationales", [])
|
||||||
|
|
||||||
|
# Ensure we have exactly 3 ideas, fallback to original if needed
|
||||||
|
if not isinstance(enhanced_ideas, list) or len(enhanced_ideas) != 3:
|
||||||
|
# Fallback: create 3 variations of the original idea
|
||||||
|
base_idea = request.idea
|
||||||
|
enhanced_ideas = [
|
||||||
|
f"Expert insights on {base_idea}: A deep dive into industry trends and best practices.",
|
||||||
|
f"The human side of {base_idea}: Personal stories and real-world experiences that resonate.",
|
||||||
|
f"Modern perspectives on {base_idea}: Current trends and forward-thinking approaches."
|
||||||
|
]
|
||||||
|
rationales = [
|
||||||
|
"Professional approach focusing on expertise and authority",
|
||||||
|
"Storytelling approach emphasizing human connection",
|
||||||
|
"Contemporary approach highlighting current relevance"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ensure rationales match the number of ideas
|
||||||
|
if not isinstance(rationales, list) or len(rationales) != 3:
|
||||||
|
rationales = [
|
||||||
|
"Professional angle with expert insights",
|
||||||
|
"Storytelling angle with human interest",
|
||||||
|
"Trendy angle with contemporary relevance"
|
||||||
|
]
|
||||||
|
|
||||||
|
return PodcastEnhanceIdeaResponse(
|
||||||
|
enhanced_ideas=enhanced_ideas[:3], # Ensure exactly 3
|
||||||
|
rationales=rationales[:3] # Ensure exactly 3
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"[Podcast Enhance] Failed for user {user_id}: {exc}")
|
||||||
|
# Fallback to basic variations of original idea
|
||||||
|
base_idea = request.idea
|
||||||
|
return PodcastEnhanceIdeaResponse(
|
||||||
|
enhanced_ideas=[
|
||||||
|
f"Expert insights on {base_idea}: A deep dive into industry trends and best practices.",
|
||||||
|
f"The human side of {base_idea}: Personal stories and real-world experiences that resonate.",
|
||||||
|
f"Modern perspectives on {base_idea}: Current trends and forward-thinking approaches."
|
||||||
|
],
|
||||||
|
rationales=[
|
||||||
|
"Professional approach focusing on expertise and authority",
|
||||||
|
"Storytelling approach emphasizing human connection",
|
||||||
|
"Contemporary approach highlighting current relevance"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyze", response_model=PodcastAnalyzeResponse)
|
||||||
|
async def analyze_podcast_idea(
|
||||||
|
request: PodcastAnalyzeRequest,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Analyze a podcast idea and return podcast-oriented outlines, keywords, and titles.
|
||||||
|
If no avatar_url is provided, it generates one automatically based on the host's look.
|
||||||
|
"""
|
||||||
|
user_id = require_authenticated_user(current_user)
|
||||||
|
|
||||||
|
# Serialize Bible context if provided or generate from onboarding
|
||||||
|
bible_context = ""
|
||||||
|
bible_obj = None
|
||||||
|
try:
|
||||||
|
bible_service = PodcastBibleService()
|
||||||
|
if request.bible:
|
||||||
|
from models.podcast_bible_models import PodcastBible
|
||||||
|
bible_data = PodcastBible(**request.bible)
|
||||||
|
bible_context = bible_service.serialize_bible(bible_data)
|
||||||
|
bible_obj = bible_data
|
||||||
|
else:
|
||||||
|
# Generate from onboarding data directly
|
||||||
|
bible_obj = bible_service.generate_bible(user_id, "temp_analyze")
|
||||||
|
bible_context = bible_service.serialize_bible(bible_obj)
|
||||||
|
bible_obj = bible_obj
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"[Podcast Analyze] Failed to parse or generate bible context: {exc}")
|
||||||
|
|
||||||
|
# --- NEW: Generate Presenter Avatar if missing ---
|
||||||
|
final_avatar_url = request.avatar_url
|
||||||
|
final_avatar_prompt = None
|
||||||
|
|
||||||
|
if not final_avatar_url:
|
||||||
|
logger.info(f"[Podcast Analyze] No avatar_url provided, generating one for user {user_id}")
|
||||||
|
try:
|
||||||
|
# 1. PRE-FLIGHT VALIDATION: Check subscription limits for image generation
|
||||||
|
from services.subscription import PricingService
|
||||||
|
from services.subscription.preflight_validator import validate_image_generation_operations
|
||||||
|
pricing_service = PricingService(db)
|
||||||
|
validate_image_generation_operations(
|
||||||
|
pricing_service=pricing_service,
|
||||||
|
user_id=user_id,
|
||||||
|
num_images=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Build avatar prompt from Bible host look or fallback
|
||||||
|
host_look = bible_obj.host.look if bible_obj and bible_obj.host.look else "A professional podcast host"
|
||||||
|
visual_style = bible_obj.visual_style.style_preset if bible_obj else "Realistic Photography"
|
||||||
|
|
||||||
|
final_avatar_prompt = f"Professional headshot of a podcast host, {host_look}, {visual_style} style, clean background, soft studio lighting, center-focused, high resolution, sharp focus, professional photography quality, 16:9 aspect ratio."
|
||||||
|
|
||||||
|
# 3. Generate the image
|
||||||
|
logger.info(f"[Podcast Analyze] Generating avatar with prompt: {final_avatar_prompt}")
|
||||||
|
image_result = generate_image(
|
||||||
|
prompt=final_avatar_prompt,
|
||||||
|
user_id=user_id,
|
||||||
|
width=1024,
|
||||||
|
height=1024
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Save to disk and library
|
||||||
|
if image_result and image_result.image_bytes:
|
||||||
|
img_id = str(uuid.uuid4())[:8]
|
||||||
|
filename = f"presenter_podcast_{user_id}_{img_id}.png"
|
||||||
|
output_path = PODCAST_IMAGES_DIR / filename
|
||||||
|
PODCAST_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(image_result.image_bytes)
|
||||||
|
|
||||||
|
final_avatar_url = f"/api/podcast/images/avatars/{filename}"
|
||||||
|
|
||||||
|
# Save to asset library for reuse
|
||||||
|
save_asset_to_library(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
asset_type="image",
|
||||||
|
file_url=final_avatar_url,
|
||||||
|
filename=filename,
|
||||||
|
title=f"Presenter Avatar - {request.idea[:40]}",
|
||||||
|
description=f"AI-generated podcast presenter for: {request.idea}",
|
||||||
|
provider=image_result.provider,
|
||||||
|
model=image_result.model,
|
||||||
|
cost=image_result.cost
|
||||||
|
)
|
||||||
|
logger.info(f"[Podcast Analyze] ✅ Generated and saved avatar to {final_avatar_url}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Podcast Analyze] ❌ Failed to generate avatar: {e}")
|
||||||
|
# Non-fatal: continue analysis even if avatar generation fails
|
||||||
|
|
||||||
|
# --- END: Avatar Generation ---
|
||||||
|
|
||||||
|
# Incorporate user feedback if provided
|
||||||
|
feedback_context = ""
|
||||||
|
if request.feedback:
|
||||||
|
feedback_context = f"""
|
||||||
|
USER REGENERATION FEEDBACK:
|
||||||
|
The user was not satisfied with the previous analysis. They provided the following instructions for improvement:
|
||||||
|
"{request.feedback}"
|
||||||
|
Please prioritize this feedback and adjust the analysis accordingly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
You are an expert podcast producer and research strategist. Given a podcast idea, craft concise podcast-ready assets
|
||||||
|
that sound like episode plans (not fiction stories).
|
||||||
|
|
||||||
|
{f"USER PERSONALIZATION CONTEXT (Podcast Bible):\n{bible_context}\n" if bible_context else ""}
|
||||||
|
{feedback_context}
|
||||||
|
|
||||||
|
Podcast Idea: "{request.idea}"
|
||||||
|
Duration: ~{request.duration} minutes
|
||||||
|
Speakers: {request.speakers} (host + optional guest)
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
1. Define the target audience and content type aligned with the Bible's "Audience DNA" and "Brand DNA".
|
||||||
|
2. Identify 5 high-impact keywords.
|
||||||
|
3. Propose 2 episode outlines with factual segments.
|
||||||
|
4. Suggest 3 titles.
|
||||||
|
5. IMPORTANT: Generate 4-6 specific research queries for Exa. These queries MUST be highly targeted to the episode's topic, the host's expertise level, and the audience's interests as defined in the Bible.
|
||||||
|
* Do NOT use generic queries like "latest trends in X".
|
||||||
|
* DO use queries that look for case studies, specific data points, expert opinions, or contrasting viewpoints that would make for a deep, insightful podcast conversation.
|
||||||
|
|
||||||
|
Return JSON with:
|
||||||
|
- audience: short target audience description
|
||||||
|
- content_type: podcast style/format
|
||||||
|
- top_keywords: 5 podcast-relevant keywords/phrases
|
||||||
|
- suggested_outlines: 2 items, each with title (<=60 chars) and 4-6 short segments (bullet-friendly, factual)
|
||||||
|
- title_suggestions: 3 concise episode titles
|
||||||
|
- research_queries: array of {{"query": "string", "rationale": "string"}}
|
||||||
|
- exa_suggested_config: suggested Exa search options with:
|
||||||
|
- exa_search_type: "auto" | "neural" | "keyword"
|
||||||
|
- exa_category: one of ["research paper","news","company","github","tweet","personal site","pdf","financial report","linkedin profile"]
|
||||||
|
- exa_include_domains: up to 3 reputable domains
|
||||||
|
- exa_exclude_domains: up to 3 domains
|
||||||
|
- max_sources: 6-10
|
||||||
|
- include_statistics: boolean
|
||||||
|
- date_range: one of ["last_month","last_3_months","last_year","all_time"]
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Keep language factual, actionable, and suited for spoken audio.
|
||||||
|
- Avoid narrative fiction tone.
|
||||||
|
- Prefer 2024-2025 context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = llm_text_gen(
|
||||||
|
prompt=prompt,
|
||||||
|
user_id=user_id,
|
||||||
|
json_struct=None,
|
||||||
|
preferred_provider="huggingface",
|
||||||
|
flow_type="premium_tool",
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
# Re-raise HTTPExceptions (e.g., 429 subscription limit) - preserve error details
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"[Podcast Analyze] Analysis failed for user {user_id}: {exc}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Analysis failed: {exc}")
|
||||||
|
|
||||||
|
# Normalize response (accept dict or JSON string)
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=500, detail="LLM returned non-JSON output")
|
||||||
|
elif isinstance(raw, dict):
|
||||||
|
data = raw
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=500, detail="Unexpected LLM response format")
|
||||||
|
|
||||||
|
audience = data.get("audience") or "Growth-focused professionals"
|
||||||
|
content_type = data.get("content_type") or "Interview + insights"
|
||||||
|
top_keywords = data.get("top_keywords") or []
|
||||||
|
suggested_outlines = data.get("suggested_outlines") or []
|
||||||
|
title_suggestions = data.get("title_suggestions") or []
|
||||||
|
research_queries = data.get("research_queries") or []
|
||||||
|
exa_suggested_config = data.get("exa_suggested_config") or None
|
||||||
|
|
||||||
|
return PodcastAnalyzeResponse(
|
||||||
|
audience=audience,
|
||||||
|
content_type=content_type,
|
||||||
|
top_keywords=top_keywords,
|
||||||
|
suggested_outlines=suggested_outlines,
|
||||||
|
title_suggestions=title_suggestions,
|
||||||
|
research_queries=research_queries,
|
||||||
|
exa_suggested_config=exa_suggested_config,
|
||||||
|
bible=bible_obj.model_dump() if bible_obj else None,
|
||||||
|
avatar_url=final_avatar_url,
|
||||||
|
avatar_prompt=final_avatar_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
422
_session_backup/models.py
Normal file
422
_session_backup/models.py
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
"""
|
||||||
|
Podcast API Models
|
||||||
|
|
||||||
|
All Pydantic request/response models for podcast endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastProjectResponse(BaseModel):
|
||||||
|
"""Response model for podcast project."""
|
||||||
|
id: int
|
||||||
|
project_id: str
|
||||||
|
user_id: str
|
||||||
|
idea: str
|
||||||
|
duration: int
|
||||||
|
speakers: int
|
||||||
|
budget_cap: float
|
||||||
|
analysis: Optional[Dict[str, Any]] = None
|
||||||
|
queries: Optional[List[Dict[str, Any]]] = None
|
||||||
|
selected_queries: Optional[List[str]] = None
|
||||||
|
research: Optional[Dict[str, Any]] = None
|
||||||
|
raw_research: Optional[Dict[str, Any]] = None
|
||||||
|
estimate: Optional[Dict[str, Any]] = None
|
||||||
|
script_data: Optional[Dict[str, Any]] = None
|
||||||
|
bible: Optional[Dict[str, Any]] = None
|
||||||
|
render_jobs: Optional[List[Dict[str, Any]]] = None
|
||||||
|
knobs: Optional[Dict[str, Any]] = None
|
||||||
|
research_provider: Optional[str] = None
|
||||||
|
show_script_editor: bool = False
|
||||||
|
show_render_queue: bool = False
|
||||||
|
current_step: Optional[str] = None
|
||||||
|
status: str = "draft"
|
||||||
|
is_favorite: bool = False
|
||||||
|
final_video_url: Optional[str] = None
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
avatar_prompt: Optional[str] = None
|
||||||
|
avatar_persona_id: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastAnalyzeRequest(BaseModel):
|
||||||
|
"""Request model for podcast idea analysis."""
|
||||||
|
idea: str = Field(..., description="Podcast topic or idea")
|
||||||
|
duration: int = Field(default=10, description="Target duration in minutes")
|
||||||
|
speakers: int = Field(default=1, description="Number of speakers")
|
||||||
|
bible: Optional[Dict[str, Any]] = Field(None, description="Optional Podcast Bible for context")
|
||||||
|
avatar_url: Optional[str] = Field(None, description="Current avatar URL if selected")
|
||||||
|
feedback: Optional[str] = Field(None, description="User feedback for regeneration")
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastAnalyzeResponse(BaseModel):
|
||||||
|
"""Response model for podcast idea analysis."""
|
||||||
|
audience: str
|
||||||
|
content_type: str
|
||||||
|
top_keywords: list[str]
|
||||||
|
suggested_outlines: list[Dict[str, Any]]
|
||||||
|
title_suggestions: list[str]
|
||||||
|
research_queries: Optional[List[Dict[str, str]]] = None
|
||||||
|
exa_suggested_config: Optional[Dict[str, Any]] = None
|
||||||
|
bible: Optional[Dict[str, Any]] = None
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
avatar_prompt: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastEnhanceIdeaRequest(BaseModel):
|
||||||
|
"""Request model for enhancing a podcast idea with AI."""
|
||||||
|
idea: str = Field(..., description="The raw podcast idea or keywords")
|
||||||
|
bible: Optional[Dict[str, Any]] = Field(None, description="Optional Podcast Bible for context")
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastEnhanceIdeaResponse(BaseModel):
|
||||||
|
"""Response model for enhanced podcast idea."""
|
||||||
|
enhanced_ideas: List[str] = Field(..., description="3 AI-enhanced topic choices")
|
||||||
|
rationales: List[str] = Field(..., description="Rationale for each enhanced idea")
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastScriptRequest(BaseModel):
|
||||||
|
"""Request model for podcast script generation."""
|
||||||
|
idea: str = Field(..., description="Podcast idea or topic")
|
||||||
|
duration_minutes: int = Field(default=10, description="Target duration in minutes")
|
||||||
|
speakers: int = Field(default=1, description="Number of speakers")
|
||||||
|
research: Optional[Dict[str, Any]] = Field(None, description="Optional research payload to ground the script")
|
||||||
|
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
|
||||||
|
outline: Optional[Dict[str, Any]] = Field(None, description="The refined episode outline to follow")
|
||||||
|
analysis: Optional[Dict[str, Any]] = Field(None, description="The full analysis context (audience, keywords, etc.)")
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastSceneLine(BaseModel):
|
||||||
|
speaker: str
|
||||||
|
text: str
|
||||||
|
emphasis: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastScene(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
duration: int
|
||||||
|
lines: list[PodcastSceneLine]
|
||||||
|
approved: bool = False
|
||||||
|
emotion: Optional[str] = None
|
||||||
|
imageUrl: Optional[str] = None # Generated image URL for video generation
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastExaConfig(BaseModel):
|
||||||
|
"""Exa config for podcast research."""
|
||||||
|
exa_search_type: Optional[str] = Field(default="auto", description="auto | keyword | neural")
|
||||||
|
exa_category: Optional[str] = None
|
||||||
|
exa_include_domains: List[str] = []
|
||||||
|
exa_exclude_domains: List[str] = []
|
||||||
|
max_sources: int = 8
|
||||||
|
include_statistics: Optional[bool] = False
|
||||||
|
date_range: Optional[str] = Field(default=None, description="last_month | last_3_months | last_year | all_time")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_domains(self):
|
||||||
|
if self.exa_include_domains and self.exa_exclude_domains:
|
||||||
|
# Exa API does not allow both include and exclude domains together with contents
|
||||||
|
# Prefer include_domains and drop exclude_domains
|
||||||
|
self.exa_exclude_domains = []
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastExaResearchRequest(BaseModel):
|
||||||
|
"""Request for podcast research using Exa directly (no blog writer)."""
|
||||||
|
topic: str
|
||||||
|
queries: List[str]
|
||||||
|
exa_config: Optional[PodcastExaConfig] = None
|
||||||
|
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
|
||||||
|
analysis: Optional[Dict[str, Any]] = Field(None, description="Podcast analysis context (audience, content type, etc.)")
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastExaSource(BaseModel):
|
||||||
|
title: str = ""
|
||||||
|
url: str = ""
|
||||||
|
excerpt: str = ""
|
||||||
|
published_at: Optional[str] = None
|
||||||
|
highlights: Optional[List[str]] = None
|
||||||
|
summary: Optional[str] = None
|
||||||
|
source_type: Optional[str] = None
|
||||||
|
index: Optional[int] = None
|
||||||
|
image: Optional[str] = None
|
||||||
|
author: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastResearchInsight(BaseModel):
|
||||||
|
"""Deep insight extracted from research."""
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
source_indices: List[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastExaResearchResponse(BaseModel):
|
||||||
|
sources: List[PodcastExaSource]
|
||||||
|
search_queries: List[str] = []
|
||||||
|
summary: str = ""
|
||||||
|
key_insights: List[PodcastResearchInsight] = []
|
||||||
|
expert_quotes: List[Dict[str, Any]] = []
|
||||||
|
listener_cta: List[str] = []
|
||||||
|
mapped_angles: List[Dict[str, Any]] = []
|
||||||
|
cost: Optional[Dict[str, Any]] = None
|
||||||
|
search_type: Optional[str] = None
|
||||||
|
provider: str = "exa"
|
||||||
|
content: Optional[str] = None # Raw aggregated content (deprecated)
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastScriptResponse(BaseModel):
|
||||||
|
scenes: list[PodcastScene]
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastAudioRequest(BaseModel):
|
||||||
|
"""Generate TTS for a podcast scene."""
|
||||||
|
scene_id: str
|
||||||
|
scene_title: str
|
||||||
|
text: str
|
||||||
|
voice_id: Optional[str] = "Wise_Woman"
|
||||||
|
speed: Optional[float] = 1.0
|
||||||
|
volume: Optional[float] = 1.0
|
||||||
|
pitch: Optional[float] = 0.0
|
||||||
|
emotion: Optional[str] = "neutral"
|
||||||
|
english_normalization: Optional[bool] = False # Better number reading for statistics
|
||||||
|
sample_rate: Optional[int] = None
|
||||||
|
bitrate: Optional[int] = None
|
||||||
|
channel: Optional[str] = None
|
||||||
|
format: Optional[str] = None
|
||||||
|
language_boost: Optional[str] = None
|
||||||
|
enable_sync_mode: Optional[bool] = True
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastAudioResponse(BaseModel):
|
||||||
|
scene_id: str
|
||||||
|
scene_title: str
|
||||||
|
audio_filename: str
|
||||||
|
audio_url: str
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
voice_id: str
|
||||||
|
text_length: int
|
||||||
|
file_size: int
|
||||||
|
cost: float
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastProjectListResponse(BaseModel):
|
||||||
|
"""Response model for project list."""
|
||||||
|
projects: List[PodcastProjectResponse]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class CreateProjectRequest(BaseModel):
|
||||||
|
"""Request model for creating a project."""
|
||||||
|
project_id: str = Field(..., description="Unique project ID")
|
||||||
|
idea: str = Field(..., description="Episode idea or URL")
|
||||||
|
duration: int = Field(..., description="Duration in minutes")
|
||||||
|
speakers: int = Field(default=1, description="Number of speakers")
|
||||||
|
budget_cap: float = Field(default=50.0, description="Budget cap in USD")
|
||||||
|
avatar_url: Optional[str] = Field(None, description="Optional presenter avatar URL")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateProjectRequest(BaseModel):
|
||||||
|
"""Request model for updating project state."""
|
||||||
|
analysis: Optional[Dict[str, Any]] = None
|
||||||
|
queries: Optional[List[Dict[str, Any]]] = None
|
||||||
|
selected_queries: Optional[List[str]] = None
|
||||||
|
research: Optional[Dict[str, Any]] = None
|
||||||
|
raw_research: Optional[Dict[str, Any]] = None
|
||||||
|
estimate: Optional[Dict[str, Any]] = None
|
||||||
|
script_data: Optional[Dict[str, Any]] = None
|
||||||
|
bible: Optional[Dict[str, Any]] = None
|
||||||
|
render_jobs: Optional[List[Dict[str, Any]]] = None
|
||||||
|
knobs: Optional[Dict[str, Any]] = None
|
||||||
|
research_provider: Optional[str] = None
|
||||||
|
show_script_editor: Optional[bool] = None
|
||||||
|
show_render_queue: Optional[bool] = None
|
||||||
|
current_step: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
final_video_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastCombineAudioRequest(BaseModel):
|
||||||
|
"""Request model for combining podcast audio files."""
|
||||||
|
project_id: str
|
||||||
|
scene_ids: List[str] = Field(..., description="List of scene IDs to combine")
|
||||||
|
scene_audio_urls: List[str] = Field(..., description="List of audio URLs for each scene")
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastCombineAudioResponse(BaseModel):
|
||||||
|
"""Response model for combined podcast audio."""
|
||||||
|
combined_audio_url: str
|
||||||
|
combined_audio_filename: str
|
||||||
|
total_duration: float
|
||||||
|
file_size: int
|
||||||
|
scene_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastImageRequest(BaseModel):
|
||||||
|
"""Request for generating an image for a podcast scene."""
|
||||||
|
scene_id: str
|
||||||
|
scene_title: str
|
||||||
|
scene_content: Optional[str] = None # Optional: scene lines text for context
|
||||||
|
idea: Optional[str] = None # Optional: podcast idea for context
|
||||||
|
base_avatar_url: Optional[str] = None # Base avatar image URL for scene variations
|
||||||
|
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
|
||||||
|
width: int = 1024
|
||||||
|
height: int = 1024
|
||||||
|
custom_prompt: Optional[str] = None # Custom prompt from user (overrides auto-generated prompt)
|
||||||
|
style: Optional[str] = None # "Auto", "Fiction", or "Realistic"
|
||||||
|
rendering_speed: Optional[str] = None # "Default", "Turbo", or "Quality"
|
||||||
|
aspect_ratio: Optional[str] = None # "1:1", "16:9", "9:16", "4:3", "3:4"
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastImageResponse(BaseModel):
|
||||||
|
"""Response for podcast scene image generation."""
|
||||||
|
scene_id: str
|
||||||
|
scene_title: str
|
||||||
|
image_filename: str
|
||||||
|
image_url: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
provider: str
|
||||||
|
model: Optional[str] = None
|
||||||
|
cost: float
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastVideoGenerationRequest(BaseModel):
|
||||||
|
"""Request model for podcast video generation."""
|
||||||
|
project_id: str = Field(..., description="Podcast project ID")
|
||||||
|
scene_id: str = Field(..., description="Scene ID")
|
||||||
|
scene_title: str = Field(..., description="Scene title")
|
||||||
|
audio_url: str = Field(..., description="URL to the generated audio file")
|
||||||
|
avatar_image_url: Optional[str] = Field(None, description="URL to scene image (required for video generation)")
|
||||||
|
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
|
||||||
|
resolution: str = Field("720p", description="Video resolution (480p or 720p)")
|
||||||
|
prompt: Optional[str] = Field(None, description="Optional animation prompt override")
|
||||||
|
seed: Optional[int] = Field(-1, description="Random seed; -1 for random")
|
||||||
|
mask_image_url: Optional[str] = Field(None, description="Optional mask image URL to specify animated region")
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastVideoGenerationResponse(BaseModel):
|
||||||
|
"""Response model for podcast video generation."""
|
||||||
|
task_id: str
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastCombineVideosRequest(BaseModel):
|
||||||
|
"""Request to combine scene videos into final podcast"""
|
||||||
|
project_id: str = Field(..., description="Project ID")
|
||||||
|
scene_video_urls: list[str] = Field(..., description="List of scene video URLs in order")
|
||||||
|
podcast_title: str = Field(default="Podcast", description="Title for the final podcast video")
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastCombineVideosResponse(BaseModel):
|
||||||
|
"""Response from combine videos endpoint"""
|
||||||
|
task_id: str
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class AudioDubbingQuality(str, Enum):
|
||||||
|
LOW = "low"
|
||||||
|
HIGH = "high"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_string(cls, value: str) -> "AudioDubbingQuality":
|
||||||
|
if value.lower() == "high":
|
||||||
|
return cls.HIGH
|
||||||
|
return cls.LOW
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastAudioDubRequest(BaseModel):
|
||||||
|
"""Request model for audio dubbing."""
|
||||||
|
source_audio_url: str = Field(..., description="URL or path to source audio file")
|
||||||
|
source_language: Optional[str] = Field(None, description="Source language code (auto-detected if None)")
|
||||||
|
target_language: str = Field(..., description="Target language for dubbing")
|
||||||
|
quality: str = Field(default="low", description="Translation quality: low (DeepL) or high (WaveSpeed)")
|
||||||
|
voice_id: Optional[str] = Field(default="Wise_Woman", description="Voice ID for TTS")
|
||||||
|
speed: Optional[float] = Field(default=1.0, ge=0.5, le=2.0, description="Speech speed (0.5-2.0)")
|
||||||
|
emotion: Optional[str] = Field(default="happy", description="Emotion for TTS voice")
|
||||||
|
preserve_emotion: Optional[bool] = Field(default=True, description="Preserve emotional tone in translation")
|
||||||
|
use_voice_clone: Optional[bool] = Field(default=False, description="Use voice cloning to preserve original speaker's voice")
|
||||||
|
custom_voice_id: Optional[str] = Field(None, description="Custom name for the cloned voice")
|
||||||
|
voice_clone_accuracy: Optional[float] = Field(default=0.7, ge=0.1, le=1.0, description="Voice cloning accuracy (0.1-1.0)")
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastAudioDubResponse(BaseModel):
|
||||||
|
"""Response model for audio dubbing task creation."""
|
||||||
|
task_id: str
|
||||||
|
status: str = "pending"
|
||||||
|
message: str = "Audio dubbing task created"
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastAudioDubResult(BaseModel):
|
||||||
|
"""Response model for completed audio dubbing."""
|
||||||
|
dubbed_audio_url: str
|
||||||
|
dubbed_audio_filename: str
|
||||||
|
original_transcript: str
|
||||||
|
translated_transcript: str
|
||||||
|
source_language: str
|
||||||
|
target_language: str
|
||||||
|
voice_id: str
|
||||||
|
quality: str
|
||||||
|
duration_seconds: int
|
||||||
|
file_size: int
|
||||||
|
cost: float
|
||||||
|
task_id: str
|
||||||
|
status: str = "completed"
|
||||||
|
voice_clone_used: Optional[bool] = Field(default=False, description="Whether voice cloning was used")
|
||||||
|
cloned_voice_id: Optional[str] = Field(None, description="ID of the cloned voice if voice_clone_used=True")
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastAudioDubEstimateRequest(BaseModel):
|
||||||
|
"""Request model for dubbing cost estimation."""
|
||||||
|
audio_duration_seconds: float = Field(..., description="Duration of source audio in seconds")
|
||||||
|
target_language: str = Field(..., description="Target language")
|
||||||
|
quality: str = Field(default="low", description="Translation quality")
|
||||||
|
use_voice_clone: Optional[bool] = Field(default=False, description="Include voice cloning cost")
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastAudioDubEstimateResponse(BaseModel):
|
||||||
|
"""Response model for dubbing cost estimation."""
|
||||||
|
estimated_characters: int
|
||||||
|
translation_cost: float
|
||||||
|
tts_cost: float
|
||||||
|
voice_clone_cost: float = 0.0
|
||||||
|
total_cost: float
|
||||||
|
currency: str = "USD"
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceCloneRequest(BaseModel):
|
||||||
|
"""Request model for voice cloning."""
|
||||||
|
source_audio_url: str = Field(..., description="URL or path to source audio file (10-60 seconds recommended)")
|
||||||
|
custom_voice_id: Optional[str] = Field(None, description="Custom name for the cloned voice")
|
||||||
|
accuracy: Optional[float] = Field(default=0.7, ge=0.1, le=1.0, description="Cloning accuracy (0.1-1.0)")
|
||||||
|
language_boost: Optional[str] = Field(None, description="Language to optimize the voice for")
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceCloneResponse(BaseModel):
|
||||||
|
"""Response model for voice cloning."""
|
||||||
|
task_id: str
|
||||||
|
status: str = "pending"
|
||||||
|
message: str = "Voice cloning task created"
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceCloneResult(BaseModel):
|
||||||
|
"""Response model for completed voice cloning."""
|
||||||
|
voice_id: str
|
||||||
|
voice_url: str
|
||||||
|
source_language: str
|
||||||
|
accuracy: float
|
||||||
|
file_size: int
|
||||||
|
task_id: str
|
||||||
|
status: str = "completed"
|
||||||
|
|
||||||
837
_session_backup/podcastApi.ts
Normal file
837
_session_backup/podcastApi.ts
Normal file
@@ -0,0 +1,837 @@
|
|||||||
|
import { ResearchProvider, ResearchConfig } from "./blogWriterApi";
|
||||||
|
import {
|
||||||
|
storyWriterApi,
|
||||||
|
StorySetupGenerationResponse,
|
||||||
|
} from "./storyWriterApi";
|
||||||
|
import { getResearchConfig, ResearchPersona } from "../api/researchConfig";
|
||||||
|
import { aiApiClient } from "../api/client";
|
||||||
|
import {
|
||||||
|
CreateProjectPayload,
|
||||||
|
CreateProjectResult,
|
||||||
|
Fact,
|
||||||
|
Knobs,
|
||||||
|
PodcastAnalysis,
|
||||||
|
PodcastEstimate,
|
||||||
|
Query,
|
||||||
|
RenderJobResult,
|
||||||
|
Research,
|
||||||
|
Scene,
|
||||||
|
Script,
|
||||||
|
} from "../components/PodcastMaker/types";
|
||||||
|
import { checkPreflight, PreflightOperation } from "./billingService";
|
||||||
|
import { TaskStatus } from "./storyWriterApi";
|
||||||
|
|
||||||
|
const DEFAULT_KNOBS: Knobs = {
|
||||||
|
voice_emotion: "neutral",
|
||||||
|
voice_speed: 1,
|
||||||
|
resolution: "720p",
|
||||||
|
scene_length_target: 45,
|
||||||
|
sample_rate: 24000,
|
||||||
|
bitrate: "standard",
|
||||||
|
};
|
||||||
|
|
||||||
|
// const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const createId = (prefix: string) => {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return `${prefix}_${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
return `${prefix}_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OptionLike = StorySetupGenerationResponse["options"][0] | { plot_elements?: string; premise?: string };
|
||||||
|
|
||||||
|
const deriveSegments = (option?: OptionLike): string[] => {
|
||||||
|
const segments: string[] = [];
|
||||||
|
if (option?.plot_elements) {
|
||||||
|
option.plot_elements
|
||||||
|
.split(/[,.;]+/)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((p) => segments.push(p));
|
||||||
|
}
|
||||||
|
if (!segments.length && "premise" in (option || {}) && (option as any)?.premise) {
|
||||||
|
segments.push("Intro", "Key Takeaways", "Examples", "CTA");
|
||||||
|
}
|
||||||
|
return segments.slice(0, 5);
|
||||||
|
};
|
||||||
|
|
||||||
|
const estimateCosts = ({
|
||||||
|
minutes,
|
||||||
|
scenes,
|
||||||
|
chars,
|
||||||
|
quality,
|
||||||
|
avatars,
|
||||||
|
queryCount = 3,
|
||||||
|
}: {
|
||||||
|
minutes: number;
|
||||||
|
scenes: number;
|
||||||
|
chars: number;
|
||||||
|
quality: string;
|
||||||
|
avatars: number;
|
||||||
|
queryCount?: number;
|
||||||
|
}): PodcastEstimate => {
|
||||||
|
const secs = Math.max(60, minutes * 60);
|
||||||
|
const ttsCost = (chars / 1000) * 0.05;
|
||||||
|
const avatarCost = avatars * 0.15;
|
||||||
|
const videoRate = quality === "hd" ? 0.06 : 0.03;
|
||||||
|
const videoCost = secs * videoRate;
|
||||||
|
const researchCost = +(Math.max(1, queryCount) * 0.1).toFixed(2);
|
||||||
|
const total = +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2);
|
||||||
|
return {
|
||||||
|
ttsCost: +ttsCost.toFixed(2),
|
||||||
|
avatarCost: +avatarCost.toFixed(2),
|
||||||
|
videoCost: +videoCost.toFixed(2),
|
||||||
|
researchCost,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string): Query[] => {
|
||||||
|
const baseIdea = seed || "AI marketing for small businesses";
|
||||||
|
const personaKeywords = persona?.suggested_keywords?.filter(Boolean) || [];
|
||||||
|
const angles = persona?.research_angles ?? [];
|
||||||
|
const generated: Query[] = [];
|
||||||
|
|
||||||
|
const addQuery = (q: string, why: string, needsRecent = false) => {
|
||||||
|
if (!q.trim()) return;
|
||||||
|
generated.push({
|
||||||
|
id: createId("q"),
|
||||||
|
query: q.trim(),
|
||||||
|
rationale: why,
|
||||||
|
needsRecentStats: needsRecent,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (personaKeywords.length) {
|
||||||
|
personaKeywords.slice(0, 4).forEach((k, idx) =>
|
||||||
|
addQuery(k, angles[idx % Math.max(1, angles.length)] || "Persona-aligned query", /202[45]|latest|trend/i.test(k))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!generated.length) {
|
||||||
|
addQuery(`How is ${baseIdea} evolving in 2024?`, "Trend + outcome focus", true);
|
||||||
|
addQuery(`Best practices for ${baseIdea}`, "Actionable guidance", false);
|
||||||
|
addQuery(`${baseIdea} case studies with ROI`, "Proof and outcomes", true);
|
||||||
|
addQuery(`${baseIdea} risks and objections`, "Address listener concerns", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return generated.slice(0, 6);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => {
|
||||||
|
if (!sources || !sources.length) return [];
|
||||||
|
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 as any).credibility_score === "number" ? (source as any).credibility_score : Math.max(0.5, 0.85 - idx * 0.02),
|
||||||
|
image: source.image,
|
||||||
|
author: source.author,
|
||||||
|
highlights: source.highlights,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExaSource = {
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
published_at?: string;
|
||||||
|
highlights?: string[];
|
||||||
|
summary?: string;
|
||||||
|
source_type?: string;
|
||||||
|
index?: number;
|
||||||
|
image?: string;
|
||||||
|
author?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExaResearchResult = {
|
||||||
|
sources: ExaSource[];
|
||||||
|
search_queries?: string[];
|
||||||
|
cost?: { total?: number };
|
||||||
|
search_type?: string;
|
||||||
|
provider?: string;
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapExaResearchResponse = (response: any): Research => {
|
||||||
|
const factCards = mapSourcesToFacts(response.sources);
|
||||||
|
// Use backend summary if available, otherwise use full content (no truncation) or fallback text
|
||||||
|
const summary = response.summary || response.content || "Research completed.";
|
||||||
|
|
||||||
|
const keyInsights = (response.key_insights || []).map((insight: any) => ({
|
||||||
|
title: insight.title || "Insight",
|
||||||
|
content: insight.content || "",
|
||||||
|
source_indices: insight.source_indices || []
|
||||||
|
}));
|
||||||
|
|
||||||
|
const expertQuotes = (response.expert_quotes || []).map((eq: any) => ({
|
||||||
|
quote: eq.quote || eq.text || "",
|
||||||
|
source_index: eq.source_index ?? 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const listenerCta = response.listener_cta || [];
|
||||||
|
|
||||||
|
const mappedAngles = (response.mapped_angles || []).map((angle: any) => ({
|
||||||
|
title: angle.title || "",
|
||||||
|
why: angle.why || angle.rationale || "",
|
||||||
|
mappedFactIds: angle.mapped_fact_ids || angle.mappedFactIds || []
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
keyInsights,
|
||||||
|
factCards,
|
||||||
|
mappedAngles,
|
||||||
|
expertQuotes,
|
||||||
|
listenerCta,
|
||||||
|
searchQueries: response.search_queries,
|
||||||
|
searchType: response.search_type,
|
||||||
|
provider: response.provider || "exa",
|
||||||
|
cost: response.cost?.total,
|
||||||
|
sourceCount: response.sources?.length || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePreflight = async (operation: PreflightOperation) => {
|
||||||
|
const result = await checkPreflight(operation);
|
||||||
|
if (!result.can_proceed) {
|
||||||
|
const message = result.operations[0]?.message || "Pre-flight validation failed";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const podcastApi = {
|
||||||
|
async createProject(payload: CreateProjectPayload, bible?: any, feedback?: string): Promise<CreateProjectResult> {
|
||||||
|
const storyIdea = payload.ideaOrUrl || "AI marketing for small businesses";
|
||||||
|
|
||||||
|
await ensurePreflight({
|
||||||
|
provider: "gemini",
|
||||||
|
operation_type: "podcast_analysis",
|
||||||
|
tokens_requested: 1500,
|
||||||
|
actual_provider_name: "gemini",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Podcast-specific analysis (not story setup)
|
||||||
|
const analysisResp = await aiApiClient.post("/api/podcast/analyze", {
|
||||||
|
idea: storyIdea,
|
||||||
|
duration: payload.duration,
|
||||||
|
speakers: payload.speakers,
|
||||||
|
bible: bible,
|
||||||
|
avatar_url: payload.avatarUrl,
|
||||||
|
feedback: feedback, // Pass feedback to backend
|
||||||
|
});
|
||||||
|
|
||||||
|
const outlines = (analysisResp.data?.suggested_outlines || []).map((o: any, idx: number) => ({
|
||||||
|
id: o.id || `outline-${idx + 1}`,
|
||||||
|
title: o.title || `Outline ${idx + 1}`,
|
||||||
|
segments: Array.isArray(o.segments) ? o.segments : deriveSegments({ plot_elements: o.segments }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const analysis: PodcastAnalysis = {
|
||||||
|
audience: analysisResp.data?.audience || "Growth-minded pros",
|
||||||
|
contentType: analysisResp.data?.content_type || "Podcast interview",
|
||||||
|
topKeywords: analysisResp.data?.top_keywords || outlines[0]?.segments?.slice(0, 3) || [],
|
||||||
|
suggestedOutlines: outlines,
|
||||||
|
suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs },
|
||||||
|
titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean),
|
||||||
|
research_queries: analysisResp.data?.research_queries || [],
|
||||||
|
exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const researchConfig = await getResearchConfig().catch(() => null);
|
||||||
|
|
||||||
|
// Use AI-generated queries if available, fallback to legacy mapping
|
||||||
|
let queries: Query[] = [];
|
||||||
|
if (analysis.research_queries && analysis.research_queries.length > 0) {
|
||||||
|
queries = analysis.research_queries.map(rq => ({
|
||||||
|
id: createId("q"),
|
||||||
|
query: rq.query,
|
||||||
|
rationale: rq.rationale,
|
||||||
|
needsRecentStats: /202[45]|latest|trend/i.test(rq.query)
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
queries = mapPersonaQueries(researchConfig?.research_persona, storyIdea);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = createId("podcast");
|
||||||
|
const estimate = estimateCosts({
|
||||||
|
minutes: payload.duration,
|
||||||
|
scenes: Math.ceil((payload.duration * 60) / (payload.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target)),
|
||||||
|
chars: Math.max(1000, payload.duration * 900),
|
||||||
|
quality: payload.knobs.bitrate || "standard",
|
||||||
|
avatars: payload.speakers,
|
||||||
|
queryCount: queries.length || 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
analysis,
|
||||||
|
estimate,
|
||||||
|
queries,
|
||||||
|
bible: analysisResp.data?.bible || undefined,
|
||||||
|
avatar_url: analysisResp.data?.avatar_url || null,
|
||||||
|
avatar_prompt: analysisResp.data?.avatar_prompt || null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async enhanceIdea(params: { idea: string; bible?: any }): Promise<{ enhanced_ideas: string[]; rationales: string[] }> {
|
||||||
|
const response = await aiApiClient.post("/api/podcast/idea/enhance", params);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async runResearch(params: {
|
||||||
|
projectId: string;
|
||||||
|
topic: string;
|
||||||
|
approvedQueries: Query[];
|
||||||
|
provider?: ResearchProvider;
|
||||||
|
exaConfig?: ResearchConfig;
|
||||||
|
bible?: any;
|
||||||
|
analysis?: PodcastAnalysis | null;
|
||||||
|
onProgress?: (message: string) => void;
|
||||||
|
}): 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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: "exa",
|
||||||
|
operation_type: "exa_neural_search",
|
||||||
|
tokens_requested: 0,
|
||||||
|
actual_provider_name: "exa",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await aiApiClient.post("/api/podcast/research/exa", {
|
||||||
|
topic: params.topic || keywords[0],
|
||||||
|
queries: keywords,
|
||||||
|
exa_config: sanitizedExaConfig,
|
||||||
|
bible: params.bible,
|
||||||
|
analysis: params.analysis,
|
||||||
|
});
|
||||||
|
|
||||||
|
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?: ExaResearchResult | null;
|
||||||
|
knobs: Knobs;
|
||||||
|
speakers: number;
|
||||||
|
durationMinutes: number;
|
||||||
|
bible?: any;
|
||||||
|
outline?: any;
|
||||||
|
analysis?: PodcastAnalysis | null;
|
||||||
|
}): Promise<Script> {
|
||||||
|
await ensurePreflight({
|
||||||
|
provider: "gemini",
|
||||||
|
operation_type: "script_generation",
|
||||||
|
tokens_requested: 2000,
|
||||||
|
actual_provider_name: "gemini",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await aiApiClient.post("/api/podcast/script", {
|
||||||
|
idea: params.idea,
|
||||||
|
duration_minutes: params.durationMinutes,
|
||||||
|
speakers: params.speakers,
|
||||||
|
research: params.research,
|
||||||
|
bible: params.bible,
|
||||||
|
outline: params.outline,
|
||||||
|
analysis: params.analysis,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scenes = response.data?.scenes || [];
|
||||||
|
const scriptScenes: Scene[] = scenes.map((scene: any) => ({
|
||||||
|
id: scene.id || createId("scene"),
|
||||||
|
title: scene.title || "Scene",
|
||||||
|
duration: scene.duration || Math.max(20, params.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target),
|
||||||
|
lines:
|
||||||
|
Array.isArray(scene.lines) && scene.lines.length
|
||||||
|
? scene.lines.map((l: any) => ({
|
||||||
|
id: createId("line"),
|
||||||
|
speaker: l.speaker || "Host",
|
||||||
|
text: l.text || "",
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: createId("line"),
|
||||||
|
speaker: "Host",
|
||||||
|
text: "Let's dive into today's topic.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
approved: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { scenes: scriptScenes };
|
||||||
|
},
|
||||||
|
|
||||||
|
async previewLine(
|
||||||
|
text: string,
|
||||||
|
options: { voiceId?: string; speed?: number; emotion?: string } = {}
|
||||||
|
): Promise<{ ok: boolean; message: string; audioUrl?: string }> {
|
||||||
|
await ensurePreflight({
|
||||||
|
provider: "audio",
|
||||||
|
operation_type: "tts_preview",
|
||||||
|
tokens_requested: text.length,
|
||||||
|
actual_provider_name: "wavespeed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await storyWriterApi.generateAIAudio({
|
||||||
|
scene_number: 0,
|
||||||
|
scene_title: "Preview",
|
||||||
|
text,
|
||||||
|
voice_id: options.voiceId || "Wise_Woman",
|
||||||
|
speed: options.speed || 1.0,
|
||||||
|
emotion: options.emotion || "neutral",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.error || "Preview failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: "Preview ready – opening audio in new tab.",
|
||||||
|
audioUrl: response.audio_url,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async renderSceneAudio(params: {
|
||||||
|
scene: Scene;
|
||||||
|
voiceId?: string;
|
||||||
|
emotion?: string; // Fallback if scene doesn't have emotion
|
||||||
|
speed?: number;
|
||||||
|
volume?: number;
|
||||||
|
pitch?: number;
|
||||||
|
englishNormalization?: boolean;
|
||||||
|
sampleRate?: number;
|
||||||
|
bitrate?: number;
|
||||||
|
channel?: "1" | "2";
|
||||||
|
format?: "mp3" | "wav" | "pcm" | "flac";
|
||||||
|
languageBoost?: string;
|
||||||
|
}): 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: 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: textToUse,
|
||||||
|
voice_id: params.voiceId || "Wise_Woman",
|
||||||
|
speed: params.speed ?? 1.0, // Normal speed (was 0.9, but too slow - causing duration issues)
|
||||||
|
volume: params.volume ?? 1.0,
|
||||||
|
pitch: params.pitch ?? 0.0,
|
||||||
|
emotion: sceneEmotion,
|
||||||
|
english_normalization: params.englishNormalization ?? true, // Better number reading for statistics
|
||||||
|
sample_rate: params.sampleRate || null,
|
||||||
|
bitrate: params.bitrate || null,
|
||||||
|
channel: params.channel || null,
|
||||||
|
format: params.format || null,
|
||||||
|
language_boost: params.languageBoost || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioUrl: response.data.audio_url,
|
||||||
|
audioFilename: response.data.audio_filename,
|
||||||
|
provider: response.data.provider,
|
||||||
|
model: response.data.model,
|
||||||
|
cost: response.data.cost,
|
||||||
|
voiceId: response.data.voice_id,
|
||||||
|
fileSize: response.data.file_size,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async approveScene(params: { projectId: string; sceneId: string; notes?: string }) {
|
||||||
|
await aiApiClient.post("/api/story/script/approve", {
|
||||||
|
project_id: params.projectId,
|
||||||
|
scene_id: params.sceneId,
|
||||||
|
approved: true,
|
||||||
|
notes: params.notes,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Project persistence endpoints
|
||||||
|
async saveProject(projectId: string, state: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
await aiApiClient.put(`/api/podcast/projects/${projectId}`, state);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save project to database:", error);
|
||||||
|
// Don't throw - localStorage fallback is acceptable
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadProject(projectId: string): Promise<any> {
|
||||||
|
const response = await aiApiClient.get(`/api/podcast/projects/${projectId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listProjects(params?: {
|
||||||
|
status?: string;
|
||||||
|
favorites_only?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
order_by?: "updated_at" | "created_at";
|
||||||
|
}): Promise<{ projects: any[]; total: number; limit: number; offset: number }> {
|
||||||
|
const response = await aiApiClient.get("/api/podcast/projects", { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createProjectInDb(params: {
|
||||||
|
project_id: string;
|
||||||
|
idea: string;
|
||||||
|
duration: number;
|
||||||
|
speakers: number;
|
||||||
|
budget_cap: number;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
}): Promise<any> {
|
||||||
|
const response = await aiApiClient.post("/api/podcast/projects", params);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateProject(projectId: string, updates: any): Promise<any> {
|
||||||
|
const response = await aiApiClient.put(`/api/podcast/projects/${projectId}`, updates);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteProject(projectId: string): Promise<void> {
|
||||||
|
await aiApiClient.delete(`/api/podcast/projects/${projectId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleFavorite(projectId: string): Promise<any> {
|
||||||
|
const response = await aiApiClient.post(`/api/podcast/projects/${projectId}/favorite`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveAudioToAssetLibrary(params: {
|
||||||
|
audioUrl: string;
|
||||||
|
filename: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
projectId: string;
|
||||||
|
sceneId?: string;
|
||||||
|
cost?: number;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
}): Promise<{ assetId: number }> {
|
||||||
|
const response = await aiApiClient.post("/api/content-assets/", {
|
||||||
|
asset_type: "audio",
|
||||||
|
source_module: "podcast_maker",
|
||||||
|
filename: params.filename,
|
||||||
|
file_url: params.audioUrl,
|
||||||
|
title: params.title,
|
||||||
|
description: params.description || `Podcast episode audio: ${params.title}`,
|
||||||
|
tags: ["podcast", "audio", params.projectId],
|
||||||
|
asset_metadata: {
|
||||||
|
project_id: params.projectId,
|
||||||
|
scene_id: params.sceneId,
|
||||||
|
provider: params.provider,
|
||||||
|
model: params.model,
|
||||||
|
},
|
||||||
|
provider: params.provider,
|
||||||
|
model: params.model,
|
||||||
|
cost: params.cost || 0,
|
||||||
|
file_size: params.fileSize,
|
||||||
|
mime_type: "audio/mpeg",
|
||||||
|
});
|
||||||
|
return { assetId: response.data.id };
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateVideo(params: {
|
||||||
|
projectId: string;
|
||||||
|
sceneId: string;
|
||||||
|
sceneTitle: string;
|
||||||
|
audioUrl: string;
|
||||||
|
avatarImageUrl?: string;
|
||||||
|
bible?: any;
|
||||||
|
resolution?: string;
|
||||||
|
prompt?: string;
|
||||||
|
seed?: number;
|
||||||
|
maskImageUrl?: string;
|
||||||
|
}): Promise<{ taskId: string; status: string; message: string }> {
|
||||||
|
const response = await aiApiClient.post("/api/podcast/render/video", {
|
||||||
|
project_id: params.projectId,
|
||||||
|
scene_id: params.sceneId,
|
||||||
|
scene_title: params.sceneTitle,
|
||||||
|
audio_url: params.audioUrl,
|
||||||
|
avatar_image_url: params.avatarImageUrl,
|
||||||
|
bible: params.bible,
|
||||||
|
resolution: params.resolution || "720p",
|
||||||
|
prompt: params.prompt,
|
||||||
|
seed: params.seed ?? -1,
|
||||||
|
mask_image_url: params.maskImageUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backend returns snake_case (task_id); normalize to camelCase for callers
|
||||||
|
const { task_id, status, message } = response.data || {};
|
||||||
|
return {
|
||||||
|
taskId: task_id,
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async pollTaskStatus(taskId: string): Promise<TaskStatus | null> {
|
||||||
|
const response = await aiApiClient.get(`/api/podcast/task/${taskId}/status`);
|
||||||
|
// Backend returns null if task not found
|
||||||
|
return response.data || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listVideos(projectId?: string): Promise<{
|
||||||
|
videos: Array<{
|
||||||
|
scene_number: number;
|
||||||
|
filename: string;
|
||||||
|
video_url: string;
|
||||||
|
file_size: number;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const params = projectId ? { project_id: projectId } : {};
|
||||||
|
const response = await aiApiClient.get("/api/podcast/videos", { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async combineVideos(params: {
|
||||||
|
projectId: string;
|
||||||
|
sceneVideoUrls: string[];
|
||||||
|
podcastTitle?: string;
|
||||||
|
}): Promise<{
|
||||||
|
taskId: string;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
}> {
|
||||||
|
const response = await aiApiClient.post("/api/podcast/render/combine-videos", {
|
||||||
|
project_id: params.projectId,
|
||||||
|
scene_video_urls: params.sceneVideoUrls,
|
||||||
|
podcast_title: params.podcastTitle || "Podcast",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { task_id, status, message } = response.data || {};
|
||||||
|
return {
|
||||||
|
taskId: task_id,
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateSceneImage(params: {
|
||||||
|
sceneId: string;
|
||||||
|
sceneTitle: string;
|
||||||
|
sceneContent?: string;
|
||||||
|
baseAvatarUrl?: string;
|
||||||
|
bible?: any;
|
||||||
|
idea?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
customPrompt?: string;
|
||||||
|
style?: "Auto" | "Fiction" | "Realistic";
|
||||||
|
renderingSpeed?: "Default" | "Turbo" | "Quality";
|
||||||
|
aspectRatio?: "1:1" | "16:9" | "9:16" | "4:3" | "3:4";
|
||||||
|
}): 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,
|
||||||
|
base_avatar_url: params.baseAvatarUrl || null,
|
||||||
|
bible: params.bible,
|
||||||
|
idea: params.idea || null,
|
||||||
|
width: params.width || 1024,
|
||||||
|
height: params.height || 1024,
|
||||||
|
custom_prompt: params.customPrompt || null,
|
||||||
|
style: params.style || null,
|
||||||
|
rendering_speed: params.renderingSpeed || null,
|
||||||
|
aspect_ratio: params.aspectRatio || null,
|
||||||
|
});
|
||||||
|
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
|
||||||
|
try {
|
||||||
|
await aiApiClient.post(`/api/story/task/${taskId}/cancel`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Task cancellation not supported:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async combineAudio(params: {
|
||||||
|
projectId: string;
|
||||||
|
sceneIds: string[];
|
||||||
|
sceneAudioUrls: string[];
|
||||||
|
}): Promise<{
|
||||||
|
combined_audio_url: string;
|
||||||
|
combined_audio_filename: string;
|
||||||
|
total_duration: number;
|
||||||
|
file_size: number;
|
||||||
|
scene_count: number;
|
||||||
|
}> {
|
||||||
|
const response = await aiApiClient.post("/api/podcast/combine-audio", {
|
||||||
|
project_id: params.projectId,
|
||||||
|
scene_ids: params.sceneIds,
|
||||||
|
scene_audio_urls: params.sceneAudioUrls,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadAvatar(file: File, projectId?: string): Promise<{ avatar_url: string; avatar_filename: string }> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (projectId) {
|
||||||
|
formData.append('project_id', projectId);
|
||||||
|
}
|
||||||
|
const response = await aiApiClient.post('/api/podcast/avatar/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async generatePresenters(
|
||||||
|
speakers: number,
|
||||||
|
projectId?: string,
|
||||||
|
audience?: string,
|
||||||
|
contentType?: string,
|
||||||
|
topKeywords?: string[]
|
||||||
|
): Promise<{
|
||||||
|
avatars: Array<{ avatar_url: string; speaker_number: number; prompt?: string; persona_id?: string; seed?: number }>;
|
||||||
|
persona_id?: string;
|
||||||
|
}> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('speakers', speakers.toString());
|
||||||
|
if (projectId) {
|
||||||
|
formData.append('project_id', projectId);
|
||||||
|
}
|
||||||
|
if (audience) {
|
||||||
|
formData.append('audience', audience);
|
||||||
|
}
|
||||||
|
if (contentType) {
|
||||||
|
formData.append('content_type', contentType);
|
||||||
|
}
|
||||||
|
if (topKeywords && Array.isArray(topKeywords) && topKeywords.length > 0) {
|
||||||
|
formData.append('top_keywords', JSON.stringify(topKeywords));
|
||||||
|
}
|
||||||
|
const response = await aiApiClient.post('/api/podcast/avatar/generate', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async makeAvatarPresentable(avatarUrl: string, projectId?: string): Promise<{ avatar_url: string; avatar_filename: string }> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar_url', avatarUrl);
|
||||||
|
if (projectId) {
|
||||||
|
formData.append('project_id', projectId);
|
||||||
|
}
|
||||||
|
const response = await aiApiClient.post('/api/podcast/avatar/make-presentable', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PodcastApi = typeof podcastApi;
|
||||||
|
|
||||||
244
_session_backup/research.py
Normal file
244
_session_backup/research.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""
|
||||||
|
Podcast Research Handlers
|
||||||
|
|
||||||
|
Research endpoints using Exa provider and LLM summarization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import json
|
||||||
|
|
||||||
|
from middleware.auth_middleware import get_current_user
|
||||||
|
from api.story_writer.utils.auth import require_authenticated_user
|
||||||
|
from services.blog_writer.research.exa_provider import ExaResearchProvider
|
||||||
|
from services.llm_providers.main_text_generation import llm_text_gen
|
||||||
|
from services.podcast_bible_service import PodcastBibleService
|
||||||
|
from loguru import logger
|
||||||
|
from ..models import (
|
||||||
|
PodcastExaResearchRequest,
|
||||||
|
PodcastExaResearchResponse,
|
||||||
|
PodcastExaSource,
|
||||||
|
PodcastExaConfig,
|
||||||
|
PodcastResearchInsight,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/research/exa", response_model=PodcastExaResearchResponse)
|
||||||
|
async def podcast_research_exa(
|
||||||
|
request: PodcastExaResearchRequest,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Run podcast research via Exa and then use LLM to extract deep insights.
|
||||||
|
Uses Podcast Bible and Analysis context for hyper-personalization.
|
||||||
|
"""
|
||||||
|
user_id = require_authenticated_user(current_user)
|
||||||
|
|
||||||
|
queries = [q.strip() for q in request.queries if q and q.strip()]
|
||||||
|
if not queries:
|
||||||
|
raise HTTPException(status_code=400, detail="At least one query is required for research.")
|
||||||
|
|
||||||
|
exa_cfg = request.exa_config or PodcastExaConfig()
|
||||||
|
cfg = SimpleNamespace(
|
||||||
|
exa_search_type=exa_cfg.exa_search_type or "auto",
|
||||||
|
exa_category=exa_cfg.exa_category,
|
||||||
|
exa_include_domains=exa_cfg.exa_include_domains or [],
|
||||||
|
exa_exclude_domains=exa_cfg.exa_exclude_domains or [],
|
||||||
|
max_sources=exa_cfg.max_sources or 8,
|
||||||
|
source_types=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = ExaResearchProvider()
|
||||||
|
|
||||||
|
# --- Context Building ---
|
||||||
|
bible_service = PodcastBibleService()
|
||||||
|
bible_context = ""
|
||||||
|
if request.bible:
|
||||||
|
try:
|
||||||
|
from models.podcast_bible_models import PodcastBible
|
||||||
|
bible_data = PodcastBible(**request.bible)
|
||||||
|
bible_context = bible_service.serialize_bible(bible_data)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"[Podcast Research] Failed to serialize bible: {exc}")
|
||||||
|
|
||||||
|
analysis_context = ""
|
||||||
|
if request.analysis:
|
||||||
|
analysis_context = f"""
|
||||||
|
PODCAST ANALYSIS CONTEXT:
|
||||||
|
Audience: {request.analysis.get('audience', 'General')}
|
||||||
|
Content Type: {request.analysis.get('content_type', 'Informative')}
|
||||||
|
Top Keywords: {', '.join(request.analysis.get('top_keywords', []))}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Exa search params
|
||||||
|
industry = request.bible.get("brand", {}).get("industry", "") if request.bible else ""
|
||||||
|
target_audience = ""
|
||||||
|
if request.bible:
|
||||||
|
audience_dna = request.bible.get("audience", {})
|
||||||
|
if audience_dna:
|
||||||
|
interests = ", ".join(audience_dna.get("interests", []))
|
||||||
|
target_audience = f"Expertise: {audience_dna.get('expertise_level', '')}. Interests: {interests}."
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. RUN EXA SEARCH
|
||||||
|
result = await provider.search(
|
||||||
|
prompt=request.topic,
|
||||||
|
topic=request.topic,
|
||||||
|
industry=industry,
|
||||||
|
target_audience=target_audience,
|
||||||
|
config=cfg,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"[Podcast Exa Research] Search failed for user {user_id}: {exc}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Exa research failed: {exc}")
|
||||||
|
|
||||||
|
# 2. EXTRACT INSIGHTS VIA LLM
|
||||||
|
raw_content = result.get("content", "")
|
||||||
|
sources = result.get("sources", [])
|
||||||
|
|
||||||
|
summary = ""
|
||||||
|
key_insights = []
|
||||||
|
expert_quotes = []
|
||||||
|
listener_cta = []
|
||||||
|
mapped_angles = []
|
||||||
|
|
||||||
|
if raw_content and sources:
|
||||||
|
logger.info(f"[Podcast Research] Extracting insights from {len(sources)} sources for user {user_id}")
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
You are an expert research analyst for a high-end podcast production team.
|
||||||
|
Your task is to analyze the following research data and extract deep, actionable insights for a podcast episode.
|
||||||
|
|
||||||
|
PODCAST CONTEXT:
|
||||||
|
Topic: {request.topic}
|
||||||
|
{bible_context}
|
||||||
|
{analysis_context}
|
||||||
|
|
||||||
|
RESEARCH DATA (from {len(sources)} sources):
|
||||||
|
{raw_content}
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
1. Provide a comprehensive summary (2-3 paragraphs) of the most important findings. Use Markdown for formatting (bolding, lists).
|
||||||
|
2. Extract 3-5 "Key Insights". Each insight should have a title and a detailed explanation.
|
||||||
|
3. For each insight, identify which source indices (e.g. 1, 2) it was derived from.
|
||||||
|
4. Extract notable "Expert Quotes" - direct quotes from industry leaders, researchers, or authoritative voices found in the sources.
|
||||||
|
5. Suggest 2-4 "Listener CTA" (call-to-action) ideas that the podcast host can use to engage the audience.
|
||||||
|
6. Identify 3-5 "Mapped Angles" - unique content angles with rationale for why they matter for this topic.
|
||||||
|
|
||||||
|
NOTE: The research data includes "Key Highlights", "Summaries", and "Excerpts" from various sources.
|
||||||
|
Pay special attention to the "Key Highlights" sections as they contain the most relevant information extracted by the neural search engine.
|
||||||
|
|
||||||
|
Return JSON structure:
|
||||||
|
{{
|
||||||
|
"summary": "Detailed markdown summary...",
|
||||||
|
"key_insights": [
|
||||||
|
{{
|
||||||
|
"title": "Insight Title",
|
||||||
|
"content": "Detailed markdown content...",
|
||||||
|
"source_indices": [1, 2]
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"expert_quotes": [
|
||||||
|
{{
|
||||||
|
"quote": "Exact quote from source...",
|
||||||
|
"source_index": 1
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"listener_cta": [
|
||||||
|
"Call-to-action suggestion 1",
|
||||||
|
"Call-to-action suggestion 2"
|
||||||
|
],
|
||||||
|
"mapped_angles": [
|
||||||
|
{{
|
||||||
|
"title": "Angle Title",
|
||||||
|
"why": "Why this angle matters for the audience...",
|
||||||
|
"mapped_fact_ids": ["fact_1", "fact_2"]
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Ensure insights are deep, not just superficial facts. Look for trends, expert opinions, and specific data points.
|
||||||
|
- Expert quotes should be exact or near-exact quotes from the sources, with attribution.
|
||||||
|
- Listener CTAs should be practical and engaging (e.g., "Share your experience with X on social media").
|
||||||
|
- Mapped angles should be unique perspectives that make the episode stand out.
|
||||||
|
- Tone should be professional, insightful, and ready for a podcast host to discuss.
|
||||||
|
- Avoid generic filler.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
llm_response = llm_text_gen(
|
||||||
|
prompt=prompt,
|
||||||
|
user_id=user_id,
|
||||||
|
json_struct=None,
|
||||||
|
preferred_provider="huggingface",
|
||||||
|
flow_type="premium_tool",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalize response
|
||||||
|
if isinstance(llm_response, str):
|
||||||
|
data = json.loads(llm_response)
|
||||||
|
else:
|
||||||
|
data = llm_response
|
||||||
|
|
||||||
|
summary = data.get("summary", "")
|
||||||
|
key_insights = [PodcastResearchInsight(**insight) for insight in data.get("key_insights", [])]
|
||||||
|
expert_quotes = data.get("expert_quotes", [])
|
||||||
|
listener_cta = data.get("listener_cta", [])
|
||||||
|
mapped_angles = data.get("mapped_angles", [])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"[Podcast Research] LLM Insight extraction failed: {exc}")
|
||||||
|
# Fallback to a basic summary if LLM fails
|
||||||
|
summary = f"Research completed for '{request.topic}'. Found {len(sources)} sources."
|
||||||
|
|
||||||
|
# Fallback: if summary is still empty (e.g. LLM returned empty string), use raw content first paragraph or basic text
|
||||||
|
if not summary:
|
||||||
|
if raw_content:
|
||||||
|
summary = raw_content[:2000] # Use first 2000 chars of raw content as summary
|
||||||
|
else:
|
||||||
|
summary = f"Research completed for '{request.topic}'. Found {len(sources)} sources."
|
||||||
|
|
||||||
|
# 3. TRACK USAGE
|
||||||
|
try:
|
||||||
|
cost_total = 0.0
|
||||||
|
if isinstance(result, dict):
|
||||||
|
cost_total = result.get("cost", {}).get("total", 0.005) if result.get("cost") else 0.005
|
||||||
|
provider.track_exa_usage(user_id, cost_total)
|
||||||
|
except Exception as track_err:
|
||||||
|
logger.warning(f"[Podcast Exa Research] Failed to track usage: {track_err}")
|
||||||
|
|
||||||
|
sources_payload = []
|
||||||
|
for src in sources:
|
||||||
|
try:
|
||||||
|
sources_payload.append(PodcastExaSource(**src))
|
||||||
|
except Exception:
|
||||||
|
sources_payload.append(PodcastExaSource(**{
|
||||||
|
"title": src.get("title", ""),
|
||||||
|
"url": src.get("url", ""),
|
||||||
|
"excerpt": src.get("excerpt", ""),
|
||||||
|
"published_at": src.get("published_at"),
|
||||||
|
"highlights": src.get("highlights"),
|
||||||
|
"summary": src.get("summary"),
|
||||||
|
"source_type": src.get("source_type"),
|
||||||
|
"index": src.get("index"),
|
||||||
|
"image": src.get("image"),
|
||||||
|
"author": src.get("author"),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return PodcastExaResearchResponse(
|
||||||
|
sources=sources_payload,
|
||||||
|
search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries,
|
||||||
|
summary=summary,
|
||||||
|
key_insights=key_insights,
|
||||||
|
expert_quotes=expert_quotes,
|
||||||
|
listener_cta=listener_cta,
|
||||||
|
mapped_angles=mapped_angles,
|
||||||
|
cost=result.get("cost") if isinstance(result, dict) else None,
|
||||||
|
search_type=result.get("search_type") if isinstance(result, dict) else None,
|
||||||
|
provider=result.get("provider", "exa") if isinstance(result, dict) else "exa",
|
||||||
|
content=raw_content,
|
||||||
|
)
|
||||||
|
|
||||||
183
_session_backup/script.py
Normal file
183
_session_backup/script.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
Podcast Script Handlers
|
||||||
|
|
||||||
|
Script generation endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from typing import Dict, Any
|
||||||
|
import json
|
||||||
|
|
||||||
|
from middleware.auth_middleware import get_current_user
|
||||||
|
from api.story_writer.utils.auth import require_authenticated_user
|
||||||
|
from services.llm_providers.main_text_generation import llm_text_gen
|
||||||
|
from services.podcast_bible_service import PodcastBibleService
|
||||||
|
from models.podcast_bible_models import PodcastBible
|
||||||
|
from loguru import logger
|
||||||
|
from ..models import (
|
||||||
|
PodcastScriptRequest,
|
||||||
|
PodcastScriptResponse,
|
||||||
|
PodcastScene,
|
||||||
|
PodcastSceneLine,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/script", response_model=PodcastScriptResponse)
|
||||||
|
async def generate_podcast_script(
|
||||||
|
request: PodcastScriptRequest,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate a podcast script outline (scenes + lines) using podcast-oriented prompting.
|
||||||
|
"""
|
||||||
|
user_id = require_authenticated_user(current_user)
|
||||||
|
|
||||||
|
# Build comprehensive research context for higher-quality scripts
|
||||||
|
research_context = ""
|
||||||
|
if request.research:
|
||||||
|
try:
|
||||||
|
key_insights = request.research.get("keyword_analysis", {}).get("key_insights") or []
|
||||||
|
fact_cards = request.research.get("factCards", []) or []
|
||||||
|
mapped_angles = request.research.get("mappedAngles", []) or []
|
||||||
|
sources = request.research.get("sources", []) or []
|
||||||
|
|
||||||
|
top_facts = [f.get("quote", "") for f in fact_cards[:5] if f.get("quote")]
|
||||||
|
angles_summary = [
|
||||||
|
f"{a.get('title', '')}: {a.get('why', '')}" for a in mapped_angles[:3] if a.get("title") or a.get("why")
|
||||||
|
]
|
||||||
|
top_sources = [s.get("url") for s in sources[:3] if s.get("url")]
|
||||||
|
|
||||||
|
research_parts = []
|
||||||
|
if key_insights:
|
||||||
|
research_parts.append(f"Key Insights: {', '.join(key_insights[:5])}")
|
||||||
|
if top_facts:
|
||||||
|
research_parts.append(f"Key Facts: {', '.join(top_facts)}")
|
||||||
|
if angles_summary:
|
||||||
|
research_parts.append(f"Research Angles: {' | '.join(angles_summary)}")
|
||||||
|
if top_sources:
|
||||||
|
research_parts.append(f"Top Sources: {', '.join(top_sources)}")
|
||||||
|
|
||||||
|
research_context = "\n".join(research_parts)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to parse research context: {exc}")
|
||||||
|
research_context = ""
|
||||||
|
|
||||||
|
# Extract Podcast Bible context for hyper-personalization
|
||||||
|
bible_context = ""
|
||||||
|
if request.bible:
|
||||||
|
try:
|
||||||
|
bible_service = PodcastBibleService()
|
||||||
|
bible_obj = PodcastBible(**request.bible)
|
||||||
|
bible_context = bible_service.serialize_bible(bible_obj)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to serialize podcast bible: {exc}")
|
||||||
|
|
||||||
|
# Extract Analysis and Outline context for grounding
|
||||||
|
analysis_context = ""
|
||||||
|
if request.analysis:
|
||||||
|
analysis_context = f"""
|
||||||
|
TARGET AUDIENCE: {request.analysis.get('audience', 'General')}
|
||||||
|
CONTENT TYPE: {request.analysis.get('contentType', 'Conversational')}
|
||||||
|
TOP KEYWORDS: {', '.join(request.analysis.get('topKeywords', []))}
|
||||||
|
"""
|
||||||
|
|
||||||
|
outline_context = ""
|
||||||
|
if request.outline:
|
||||||
|
outline_context = f"""
|
||||||
|
REFINED EPISODE OUTLINE (Follow this structure closely):
|
||||||
|
Title: {request.outline.get('title', 'N/A')}
|
||||||
|
Segments: {' | '.join(request.outline.get('segments', []))}
|
||||||
|
"""
|
||||||
|
|
||||||
|
prompt = f"""You are an expert podcast script planner. Create natural, conversational podcast scenes.
|
||||||
|
|
||||||
|
{f"PODCAST BIBLE (Hyper-Personalization Context):\n{bible_context}\n" if bible_context else ""}
|
||||||
|
{f"ANALYSIS CONTEXT:\n{analysis_context}\n" if analysis_context else ""}
|
||||||
|
{f"REFINED OUTLINE:\n{outline_context}\n" if outline_context else ""}
|
||||||
|
|
||||||
|
Podcast Idea: "{request.idea}"
|
||||||
|
Duration: ~{request.duration_minutes} minutes
|
||||||
|
Speakers: {request.speakers} (Host + optional Guest)
|
||||||
|
|
||||||
|
{f"RESEARCH CONTEXT:\n{research_context}\n" if research_context else ""}
|
||||||
|
|
||||||
|
Return JSON with:
|
||||||
|
- scenes: array of scenes. Each scene has:
|
||||||
|
- id: string
|
||||||
|
- title: short scene title (<= 60 chars)
|
||||||
|
- duration: duration in seconds (evenly split across total duration)
|
||||||
|
- emotion: string (one of: "neutral", "happy", "excited", "serious", "curious", "confident")
|
||||||
|
- lines: array of {{"speaker": "...", "text": "...", "emphasis": boolean}}
|
||||||
|
* Write natural, conversational dialogue
|
||||||
|
* Each line can be a sentence or a few sentences that flow together
|
||||||
|
* Use plain text only - no markdown formatting (no asterisks, underscores, etc.)
|
||||||
|
* Mark "emphasis": true for key statistics or important points
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Write for spoken delivery: conversational, natural, with contractions.
|
||||||
|
- Follow the interaction tone specified in the Bible.
|
||||||
|
- Ensure the Host persona matches the background and personality traits from the Bible.
|
||||||
|
- Structure the intro and outro scenes according to the Bible's "Intro Format" and "Outro Format".
|
||||||
|
- Adhere to any constraints mentioned in the Bible.
|
||||||
|
- Use insights from the Research Context to ground the conversation in facts.
|
||||||
|
- IMPORTANT: Follow the REFINED OUTLINE segments as the primary structure for the episode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = llm_text_gen(
|
||||||
|
prompt=prompt,
|
||||||
|
user_id=user_id,
|
||||||
|
json_struct=None,
|
||||||
|
preferred_provider="huggingface",
|
||||||
|
flow_type="premium_tool",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Script generation failed: {exc}")
|
||||||
|
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=500, detail="LLM returned non-JSON output")
|
||||||
|
elif isinstance(raw, dict):
|
||||||
|
data = raw
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=500, detail="Unexpected LLM response format")
|
||||||
|
|
||||||
|
scenes_data = data.get("scenes") or []
|
||||||
|
if not isinstance(scenes_data, list):
|
||||||
|
raise HTTPException(status_code=500, detail="LLM response missing scenes array")
|
||||||
|
|
||||||
|
valid_emotions = {"neutral", "happy", "excited", "serious", "curious", "confident"}
|
||||||
|
|
||||||
|
# Normalize scenes
|
||||||
|
scenes: list[PodcastScene] = []
|
||||||
|
for idx, scene in enumerate(scenes_data):
|
||||||
|
title = scene.get("title") or f"Scene {idx + 1}"
|
||||||
|
duration = int(scene.get("duration") or max(30, (request.duration_minutes * 60) // max(1, len(scenes_data))))
|
||||||
|
emotion = scene.get("emotion") or "neutral"
|
||||||
|
if emotion not in valid_emotions:
|
||||||
|
emotion = "neutral"
|
||||||
|
lines_raw = scene.get("lines") or []
|
||||||
|
lines: list[PodcastSceneLine] = []
|
||||||
|
for line in lines_raw:
|
||||||
|
speaker = line.get("speaker") or ("Host" if len(lines) % request.speakers == 0 else "Guest")
|
||||||
|
text = line.get("text") or ""
|
||||||
|
emphasis = line.get("emphasis", False)
|
||||||
|
if text:
|
||||||
|
lines.append(PodcastSceneLine(speaker=speaker, text=text, emphasis=emphasis))
|
||||||
|
scenes.append(
|
||||||
|
PodcastScene(
|
||||||
|
id=scene.get("id") or f"scene-{idx + 1}",
|
||||||
|
title=title,
|
||||||
|
duration=duration,
|
||||||
|
lines=lines,
|
||||||
|
approved=False,
|
||||||
|
emotion=emotion,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return PodcastScriptResponse(scenes=scenes)
|
||||||
|
|
||||||
209
_session_backup/types.ts
Normal file
209
_session_backup/types.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
export type Knobs = {
|
||||||
|
voice_emotion: string;
|
||||||
|
voice_speed: number;
|
||||||
|
resolution: string;
|
||||||
|
scene_length_target: number;
|
||||||
|
sample_rate: number;
|
||||||
|
bitrate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Query = {
|
||||||
|
id: string;
|
||||||
|
query: string;
|
||||||
|
rationale: string;
|
||||||
|
needsRecentStats: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Fact = {
|
||||||
|
id: string;
|
||||||
|
quote: string;
|
||||||
|
url: string;
|
||||||
|
date: string;
|
||||||
|
confidence: number;
|
||||||
|
image?: string;
|
||||||
|
author?: string;
|
||||||
|
highlights?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResearchInsight = {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
source_indices: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Research = {
|
||||||
|
summary: string;
|
||||||
|
keyInsights: ResearchInsight[];
|
||||||
|
factCards: Fact[];
|
||||||
|
mappedAngles: {
|
||||||
|
title: string;
|
||||||
|
why: string;
|
||||||
|
mappedFactIds: string[];
|
||||||
|
}[];
|
||||||
|
searchQueries?: string[];
|
||||||
|
searchType?: string;
|
||||||
|
provider?: string;
|
||||||
|
cost?: number;
|
||||||
|
sourceCount?: number;
|
||||||
|
expertQuotes?: { quote: string; source_index: number }[];
|
||||||
|
listenerCta?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Line = {
|
||||||
|
id: string;
|
||||||
|
speaker: string;
|
||||||
|
text: string;
|
||||||
|
usedFactIds?: string[];
|
||||||
|
emphasis?: boolean; // Mark lines that need vocal emphasis
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Scene = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
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 = {
|
||||||
|
scenes: Scene[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JobStatus =
|
||||||
|
| "idle"
|
||||||
|
| "previewing"
|
||||||
|
| "queued"
|
||||||
|
| "running"
|
||||||
|
| "completed"
|
||||||
|
| "cancelled"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export type Job = {
|
||||||
|
sceneId: string;
|
||||||
|
title: string;
|
||||||
|
status: JobStatus;
|
||||||
|
progress: number;
|
||||||
|
previewUrl?: string | null;
|
||||||
|
finalUrl?: string | null;
|
||||||
|
videoUrl?: string | null;
|
||||||
|
jobId?: string | null;
|
||||||
|
taskId?: string | null;
|
||||||
|
cost?: number | null;
|
||||||
|
provider?: string | null;
|
||||||
|
voiceId?: string | null;
|
||||||
|
fileSize?: number | null;
|
||||||
|
avatarImageUrl?: string | null;
|
||||||
|
imageUrl?: string | null; // Scene-specific image URL
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PodcastAnalysis = {
|
||||||
|
audience: string;
|
||||||
|
contentType: string;
|
||||||
|
topKeywords: string[];
|
||||||
|
suggestedOutlines: { id: number | string; title: string; segments: string[] }[];
|
||||||
|
suggestedKnobs: Knobs;
|
||||||
|
titleSuggestions: string[];
|
||||||
|
research_queries?: { query: string; rationale: 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 = {
|
||||||
|
ttsCost: number;
|
||||||
|
avatarCost: number;
|
||||||
|
videoCost: number;
|
||||||
|
researchCost: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HostPersona = {
|
||||||
|
name: string;
|
||||||
|
background: string;
|
||||||
|
expertise_level: string;
|
||||||
|
personality_traits: string[];
|
||||||
|
vocal_style: string;
|
||||||
|
catchphrases: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AudienceDNA = {
|
||||||
|
expertise_level: string;
|
||||||
|
interests: string[];
|
||||||
|
pain_points: string[];
|
||||||
|
demographics?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrandDNA = {
|
||||||
|
industry: string;
|
||||||
|
tone: string;
|
||||||
|
communication_style: string;
|
||||||
|
key_messages: string[];
|
||||||
|
competitor_context?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PodcastBible = {
|
||||||
|
project_id?: string;
|
||||||
|
host: HostPersona;
|
||||||
|
audience: AudienceDNA;
|
||||||
|
brand: BrandDNA;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateProjectPayload = {
|
||||||
|
ideaOrUrl: string;
|
||||||
|
speakers: number;
|
||||||
|
duration: number;
|
||||||
|
knobs: Knobs;
|
||||||
|
budgetCap: number;
|
||||||
|
files: { voiceFile?: File | null; avatarFile?: File | null };
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateProjectResult = {
|
||||||
|
projectId: string;
|
||||||
|
analysis: PodcastAnalysis;
|
||||||
|
estimate: PodcastEstimate;
|
||||||
|
queries: Query[];
|
||||||
|
bible?: PodcastBible;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
avatar_prompt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RenderJobResult = {
|
||||||
|
audioUrl: string;
|
||||||
|
audioFilename: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
cost: number;
|
||||||
|
voiceId: string;
|
||||||
|
fileSize: number;
|
||||||
|
videoUrl?: string;
|
||||||
|
videoFilename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface VideoGenerationSettings {
|
||||||
|
prompt: string;
|
||||||
|
resolution: "480p" | "720p";
|
||||||
|
seed?: number | null;
|
||||||
|
maskImageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskStatus = {
|
||||||
|
task_id: string;
|
||||||
|
status: "pending" | "processing" | "completed" | "failed";
|
||||||
|
progress?: number;
|
||||||
|
message?: string;
|
||||||
|
result?: any;
|
||||||
|
error?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
425
_session_backup/usePodcastWorkflow.ts
Normal file
425
_session_backup/usePodcastWorkflow.ts
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
|
import { podcastApi } from "../../../services/podcastApi";
|
||||||
|
import { usePreflightCheck } from "../../../hooks/usePreflightCheck";
|
||||||
|
import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
|
||||||
|
import { CreateProjectPayload, Script } 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,
|
||||||
|
setBible,
|
||||||
|
} = 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);
|
||||||
|
}
|
||||||
|
}, [project, currentStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (announcement) {
|
||||||
|
const t = setTimeout(() => setAnnouncement(""), 4000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [announcement]);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async (payload: CreateProjectPayload, feedback?: string) => {
|
||||||
|
if (isAnalyzing) return;
|
||||||
|
setResearch(null);
|
||||||
|
setRawResearch(null);
|
||||||
|
setScriptData(null);
|
||||||
|
setShowScriptEditor(false);
|
||||||
|
setShowRenderQueue(false);
|
||||||
|
try {
|
||||||
|
setIsAnalyzing(true);
|
||||||
|
|
||||||
|
// Use existing avatar URL if provided (e.g. brand avatar), or upload new file
|
||||||
|
let avatarUrl: string | null = payload.avatarUrl || null;
|
||||||
|
if (payload.files.avatarFile) {
|
||||||
|
try {
|
||||||
|
setAnnouncement("Uploading presenter avatar...");
|
||||||
|
const uploadResponse = await podcastApi.uploadAvatar(payload.files.avatarFile);
|
||||||
|
avatarUrl = uploadResponse.avatar_url;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Avatar upload failed:', error);
|
||||||
|
// Continue without avatar - will generate one later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW FLOW: Create project first to generate/get the Podcast Bible
|
||||||
|
// This allows the analysis to be personalized using the Bible context
|
||||||
|
const projectId = project?.id || `podcast_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||||
|
setAnnouncement("Initializing project and brand context...");
|
||||||
|
const dbProject = project ? null : await initializeProject(payload, projectId, avatarUrl);
|
||||||
|
const bible = dbProject?.bible || projectState.bible;
|
||||||
|
|
||||||
|
setAnnouncement(feedback ? "Regenerating analysis using your feedback..." : "Analyzing your idea — AI suggestions incoming");
|
||||||
|
const result = await podcastApi.createProject(payload, bible, feedback);
|
||||||
|
|
||||||
|
if (result.bible) {
|
||||||
|
setBible(result.bible);
|
||||||
|
} else if (dbProject?.bible) {
|
||||||
|
setBible(dbProject.bible);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the project in database with the analysis results
|
||||||
|
try {
|
||||||
|
await podcastApi.updateProject(projectId, {
|
||||||
|
analysis: result.analysis,
|
||||||
|
estimate: result.estimate,
|
||||||
|
queries: result.queries,
|
||||||
|
selected_queries: result.queries.map(q => q.id),
|
||||||
|
avatar_url: result.avatar_url,
|
||||||
|
avatar_prompt: result.avatar_prompt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update project with analysis results:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
setProject({
|
||||||
|
id: projectId,
|
||||||
|
idea: payload.ideaOrUrl,
|
||||||
|
duration: payload.duration,
|
||||||
|
speakers: payload.speakers,
|
||||||
|
avatarUrl: result.avatar_url || avatarUrl,
|
||||||
|
avatarPrompt: result.avatar_prompt || null,
|
||||||
|
avatarPersonaId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
setAnalysis(result.analysis);
|
||||||
|
setEstimate(result.estimate);
|
||||||
|
setQueries(result.queries);
|
||||||
|
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
|
||||||
|
setKnobs(payload.knobs);
|
||||||
|
setBudgetCap(payload.budgetCap);
|
||||||
|
|
||||||
|
// Generate presenters AFTER analysis completes (to use analysis insights)
|
||||||
|
// This happens only if no avatar was uploaded
|
||||||
|
if (!avatarUrl && payload.speakers > 0 && result.analysis) {
|
||||||
|
try {
|
||||||
|
setAnnouncement("Generating presenter avatars using AI insights...");
|
||||||
|
const presentersResponse = await podcastApi.generatePresenters(
|
||||||
|
payload.speakers,
|
||||||
|
result.projectId,
|
||||||
|
result.analysis.audience,
|
||||||
|
result.analysis.contentType,
|
||||||
|
result.analysis.topKeywords
|
||||||
|
);
|
||||||
|
if (presentersResponse.avatars && presentersResponse.avatars.length > 0) {
|
||||||
|
// Store the first presenter avatar URL and prompt
|
||||||
|
const firstAvatar = presentersResponse.avatars[0];
|
||||||
|
const prompt = firstAvatar.prompt || null;
|
||||||
|
setProject({
|
||||||
|
id: result.projectId,
|
||||||
|
idea: payload.ideaOrUrl,
|
||||||
|
duration: payload.duration,
|
||||||
|
speakers: payload.speakers,
|
||||||
|
avatarUrl: firstAvatar.avatar_url,
|
||||||
|
avatarPrompt: prompt,
|
||||||
|
avatarPersonaId: firstAvatar.persona_id || presentersResponse.persona_id || null,
|
||||||
|
});
|
||||||
|
setAnnouncement("Analysis complete - Presenter avatars generated");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Presenter generation failed:', error);
|
||||||
|
setAnnouncement("Analysis complete - Avatar generation will happen later");
|
||||||
|
// Continue without presenters - can generate later
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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, setBible]);
|
||||||
|
|
||||||
|
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),
|
||||||
|
bible: projectState.bible,
|
||||||
|
analysis: analysis,
|
||||||
|
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, projectState.bible]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await podcastApi.generateScript({
|
||||||
|
projectId: project.id,
|
||||||
|
idea: project.idea,
|
||||||
|
research: rawResearch,
|
||||||
|
knobs: projectState.knobs,
|
||||||
|
speakers: project.speakers,
|
||||||
|
durationMinutes: project.duration,
|
||||||
|
bible: projectState.bible,
|
||||||
|
outline: analysis?.suggestedOutlines?.[0], // Pass the first (possibly refined) outline
|
||||||
|
analysis: analysis, // Pass full analysis context
|
||||||
|
});
|
||||||
|
|
||||||
|
setScriptData(result);
|
||||||
|
} catch (error) {
|
||||||
|
announceError(setAnnouncement, error);
|
||||||
|
}
|
||||||
|
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible])
|
||||||
|
|
||||||
|
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 (currentStep === 'research' || research) return 1;
|
||||||
|
if (currentStep === 'analysis' || analysis) return 0;
|
||||||
|
return -1;
|
||||||
|
}, [showRenderQueue, showScriptEditor, currentStep, research, analysis]);
|
||||||
|
|
||||||
|
const canGenerateScript = Boolean(project && research && rawResearch);
|
||||||
|
|
||||||
|
const handleRegenerate = useCallback(async (feedback?: string) => {
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
// Prepare the payload from existing project state
|
||||||
|
const payload: CreateProjectPayload = {
|
||||||
|
ideaOrUrl: project.idea,
|
||||||
|
duration: project.duration,
|
||||||
|
speakers: project.speakers,
|
||||||
|
knobs: projectState.knobs,
|
||||||
|
budgetCap: projectState.budgetCap,
|
||||||
|
avatarUrl: project.avatarUrl,
|
||||||
|
files: {} // No new files for regeneration
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleCreate(payload, feedback);
|
||||||
|
}, [project, projectState.knobs, projectState.budgetCap, handleCreate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isAnalyzing,
|
||||||
|
isResearching,
|
||||||
|
announcement,
|
||||||
|
showResumeAlert,
|
||||||
|
showPreflightDialog,
|
||||||
|
preflightResponse,
|
||||||
|
preflightOperationName,
|
||||||
|
activeStep,
|
||||||
|
canGenerateScript,
|
||||||
|
// Handlers
|
||||||
|
handleCreate,
|
||||||
|
handleRegenerate,
|
||||||
|
handleRunResearch,
|
||||||
|
handleGenerateScript,
|
||||||
|
handleProceedToRendering,
|
||||||
|
toggleQuery,
|
||||||
|
setAnnouncement,
|
||||||
|
setShowResumeAlert,
|
||||||
|
setShowPreflightDialog,
|
||||||
|
setPreflightResponse,
|
||||||
|
setResearchProvider,
|
||||||
|
getStepLabel,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
184
add_missing_columns.py
Normal file
184
add_missing_columns.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to add missing columns to usage_summaries table.
|
||||||
|
Run this once to fix the database schema.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python add_missing_columns.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def get_db_path():
|
||||||
|
"""Find the database path."""
|
||||||
|
possible_paths = [
|
||||||
|
Path(__file__).parent / "backend" / "alwrity.db",
|
||||||
|
Path(__file__).parent.parent / "backend" / "alwrity.db",
|
||||||
|
Path("C:/Users/diksha rawat/Desktop/ALwrity_github/windsurf/ALwrity/backend/alwrity.db"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for db_path in possible_paths:
|
||||||
|
if db_path.exists():
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
backend_dir = Path(__file__).parent / "backend"
|
||||||
|
if backend_dir.exists():
|
||||||
|
db_files = list(backend_dir.glob("*.db"))
|
||||||
|
if db_files:
|
||||||
|
print(f"Found database: {db_files[0]}")
|
||||||
|
return db_files[0]
|
||||||
|
|
||||||
|
raise FileNotFoundError(f"Database not found. Searched: {possible_paths}")
|
||||||
|
|
||||||
|
def create_usage_summaries_table(cursor):
|
||||||
|
"""Create the usage_summaries table if it doesn't exist."""
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS usage_summaries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id VARCHAR(100) NOT NULL,
|
||||||
|
billing_period VARCHAR(20) NOT NULL,
|
||||||
|
|
||||||
|
-- API Call Counts
|
||||||
|
gemini_calls INTEGER DEFAULT 0,
|
||||||
|
openai_calls INTEGER DEFAULT 0,
|
||||||
|
anthropic_calls INTEGER DEFAULT 0,
|
||||||
|
mistral_calls INTEGER DEFAULT 0,
|
||||||
|
wavespeed_calls INTEGER DEFAULT 0,
|
||||||
|
tavily_calls INTEGER DEFAULT 0,
|
||||||
|
serper_calls INTEGER DEFAULT 0,
|
||||||
|
metaphor_calls INTEGER DEFAULT 0,
|
||||||
|
firecrawl_calls INTEGER DEFAULT 0,
|
||||||
|
stability_calls INTEGER DEFAULT 0,
|
||||||
|
exa_calls INTEGER DEFAULT 0,
|
||||||
|
video_calls INTEGER DEFAULT 0,
|
||||||
|
image_edit_calls INTEGER DEFAULT 0,
|
||||||
|
audio_calls INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Token Usage
|
||||||
|
gemini_tokens INTEGER DEFAULT 0,
|
||||||
|
openai_tokens INTEGER DEFAULT 0,
|
||||||
|
anthropic_tokens INTEGER DEFAULT 0,
|
||||||
|
mistral_tokens INTEGER DEFAULT 0,
|
||||||
|
wavespeed_tokens INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Cost Tracking
|
||||||
|
gemini_cost REAL DEFAULT 0.0,
|
||||||
|
openai_cost REAL DEFAULT 0.0,
|
||||||
|
anthropic_cost REAL DEFAULT 0.0,
|
||||||
|
mistral_cost REAL DEFAULT 0.0,
|
||||||
|
wavespeed_cost REAL DEFAULT 0.0,
|
||||||
|
tavily_cost REAL DEFAULT 0.0,
|
||||||
|
serper_cost REAL DEFAULT 0.0,
|
||||||
|
metaphor_cost REAL DEFAULT 0.0,
|
||||||
|
firecrawl_cost REAL DEFAULT 0.0,
|
||||||
|
stability_cost REAL DEFAULT 0.0,
|
||||||
|
exa_cost REAL DEFAULT 0.0,
|
||||||
|
video_cost REAL DEFAULT 0.0,
|
||||||
|
image_edit_cost REAL DEFAULT 0.0,
|
||||||
|
audio_cost REAL DEFAULT 0.0,
|
||||||
|
|
||||||
|
-- Totals
|
||||||
|
total_calls INTEGER DEFAULT 0,
|
||||||
|
total_tokens INTEGER DEFAULT 0,
|
||||||
|
total_cost REAL DEFAULT 0.0,
|
||||||
|
|
||||||
|
-- Performance Metrics
|
||||||
|
avg_response_time REAL DEFAULT 0.0,
|
||||||
|
error_rate REAL DEFAULT 0.0,
|
||||||
|
usage_status VARCHAR(20) DEFAULT 'active',
|
||||||
|
warnings_sent INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(user_id, billing_period)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print("Created usage_summaries table")
|
||||||
|
|
||||||
|
def add_missing_columns():
|
||||||
|
db_path = get_db_path()
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check what tables exist
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables = [row[0] for row in cursor.fetchall()]
|
||||||
|
print(f"Tables in database: {tables}")
|
||||||
|
|
||||||
|
# Check if usage_summaries exists
|
||||||
|
if "usage_summaries" not in tables:
|
||||||
|
print("usage_summaries table doesn't exist. Creating it...")
|
||||||
|
create_usage_summaries_table(cursor)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("Done! Table created successfully.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get existing columns
|
||||||
|
cursor.execute("PRAGMA table_info(usage_summaries)")
|
||||||
|
existing_columns = {row[1] for row in cursor.fetchall()}
|
||||||
|
print(f"Existing columns in usage_summaries: {len(existing_columns)}")
|
||||||
|
|
||||||
|
# Columns to add (name, type, default)
|
||||||
|
columns_to_add = [
|
||||||
|
# Call counts
|
||||||
|
("wavespeed_calls", "INTEGER", "0"),
|
||||||
|
("tavily_calls", "INTEGER", "0"),
|
||||||
|
("serper_calls", "INTEGER", "0"),
|
||||||
|
("metaphor_calls", "INTEGER", "0"),
|
||||||
|
("firecrawl_calls", "INTEGER", "0"),
|
||||||
|
("stability_calls", "INTEGER", "0"),
|
||||||
|
("exa_calls", "INTEGER", "0"),
|
||||||
|
("video_calls", "INTEGER", "0"),
|
||||||
|
("image_edit_calls", "INTEGER", "0"),
|
||||||
|
("audio_calls", "INTEGER", "0"),
|
||||||
|
# Token usage
|
||||||
|
("wavespeed_tokens", "INTEGER", "0"),
|
||||||
|
# Cost tracking
|
||||||
|
("wavespeed_cost", "REAL", "0.0"),
|
||||||
|
("tavily_cost", "REAL", "0.0"),
|
||||||
|
("serper_cost", "REAL", "0.0"),
|
||||||
|
("metaphor_cost", "REAL", "0.0"),
|
||||||
|
("firecrawl_cost", "REAL", "0.0"),
|
||||||
|
("stability_cost", "REAL", "0.0"),
|
||||||
|
("exa_cost", "REAL", "0.0"),
|
||||||
|
("video_cost", "REAL", "0.0"),
|
||||||
|
("image_edit_cost", "REAL", "0.0"),
|
||||||
|
("audio_cost", "REAL", "0.0"),
|
||||||
|
]
|
||||||
|
|
||||||
|
added = []
|
||||||
|
skipped = []
|
||||||
|
|
||||||
|
for col_name, col_type, default in columns_to_add:
|
||||||
|
if col_name in existing_columns:
|
||||||
|
skipped.append(col_name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
sql = f"ALTER TABLE usage_summaries ADD COLUMN {col_name} {col_type} DEFAULT {default}"
|
||||||
|
cursor.execute(sql)
|
||||||
|
added.append(col_name)
|
||||||
|
print(f" Added: {col_name}")
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f" Error adding {col_name}: {e}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"\nSummary:")
|
||||||
|
print(f" Added: {len(added)} columns")
|
||||||
|
print(f" Skipped (already exist): {len(skipped)} columns")
|
||||||
|
|
||||||
|
if added:
|
||||||
|
print(f"\nColumns added: {', '.join(added)}")
|
||||||
|
if skipped:
|
||||||
|
print(f"Already existed: {', '.join(skipped)}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
add_missing_columns()
|
||||||
1
backend/emojis.txt
Normal file
1
backend/emojis.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{'🎙', '🛑', '🚀', '📖', '💳', '📈', '🌐', '📊', '📦', '🔧', '🔍'}
|
||||||
530
docs/Podcast Maker/AUDIO_ONLY_PODCAST_OPTIMIZATION.md
Normal file
530
docs/Podcast Maker/AUDIO_ONLY_PODCAST_OPTIMIZATION.md
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
# Audio-Only Podcast Optimization Plan
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document outlines the optimization strategy for audio-only podcasts in ALwrity's Podcast Maker. The goal is to maximize the character throughput per API request while maintaining cost efficiency and audio quality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Current Cost Analysis
|
||||||
|
|
||||||
|
### 1.1 Pricing Structure
|
||||||
|
|
||||||
|
| Service | Provider | Cost Formula | Notes |
|
||||||
|
|---------|----------|--------------|-------|
|
||||||
|
| **TTS (Audio)** | Minimax Speech-02-HD (WaveSpeed) | $0.05 per 1,000 chars | Exact billing per character |
|
||||||
|
| **Voice Clone** | Minimax Voice Clone | $0.50 per clone | One-time if using custom voice |
|
||||||
|
| **Research** | Exa Neural Search | $0.005 per query | + ~$0.001 for LLM insight extraction |
|
||||||
|
| **Avatar** | Ideogram Character | $0.10 per image | Only if AI-generated |
|
||||||
|
|
||||||
|
### 1.2 Cost Examples
|
||||||
|
|
||||||
|
| Podcast Duration | Characters (est.) | TTS Cost | Total Cost (audio-only) |
|
||||||
|
|------------------|-------------------|----------|--------------------------|
|
||||||
|
| 1 minute | 750 | $0.04 | $0.07 |
|
||||||
|
| 3 minutes | 2,250 | $0.11 | $0.14 |
|
||||||
|
| 5 minutes | 3,750 | $0.19 | $0.22 |
|
||||||
|
| 10 minutes | 7,500 | $0.38 | $0.41 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Technical Constraints
|
||||||
|
|
||||||
|
### 2.1 API Limits
|
||||||
|
|
||||||
|
**Backend**: `main_audio_generation.py` (line 100)
|
||||||
|
```python
|
||||||
|
if len(text) > 10000:
|
||||||
|
raise ValueError(f"Text is too long ({len(text)} characters). Maximum is 10,000 characters.")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current Limit**: 10,000 characters per single API request
|
||||||
|
|
||||||
|
### 2.2 Scene-Based Architecture
|
||||||
|
|
||||||
|
- Each scene = 1 API call
|
||||||
|
- Default scene length: 45 seconds (`scene_length_target` knob)
|
||||||
|
- Audio is generated per scene, then concatenated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Optimization Strategies
|
||||||
|
|
||||||
|
### 3.1 Strategy 1: Fewer, Longer Scenes
|
||||||
|
|
||||||
|
**Problem**: More scenes = more API calls = higher costs
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- Increase `scene_length_target` from 45s to 60s or 90s
|
||||||
|
- Fewer scenes for the same podcast duration
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
| Duration | Scenes (45s) | Scenes (60s) | Scenes (90s) | API Call Savings |
|
||||||
|
|----------|-------------|--------------|--------------|------------------|
|
||||||
|
| 5 min | 7 | 5 | 3 | 57% fewer calls |
|
||||||
|
| 10 min | 13 | 10 | 7 | 46% fewer calls |
|
||||||
|
|
||||||
|
### 3.2 Strategy 2: Per-Scene Character Budgeting
|
||||||
|
|
||||||
|
**Current behavior**: Each scene text is sent separately to TTS API
|
||||||
|
|
||||||
|
**Optimization options**:
|
||||||
|
|
||||||
|
1. **Text Concatenation**: Combine multiple scene texts with `<#x#>` pause markers
|
||||||
|
```python
|
||||||
|
# Example: Combine scenes with pause markers
|
||||||
|
combined_text = "Scene 1 text.<#x#>Scene 2 text.<#x#>Scene 3 text."
|
||||||
|
```
|
||||||
|
- Risk: May hit 10,000 char limit faster
|
||||||
|
- Benefit: Single API call for multiple scenes
|
||||||
|
|
||||||
|
2. **Smart Chunking**: Dynamically batch scenes based on character count
|
||||||
|
```python
|
||||||
|
MAX_CHARS_PER_REQUEST = 9500 # Leave buffer
|
||||||
|
# Group scenes until approaching limit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Strategy 3: Voice Settings for Longer Content
|
||||||
|
|
||||||
|
**Speed factor impacts**:
|
||||||
|
- Speed 0.8 = 25% more content per same duration
|
||||||
|
- Speed 1.2 = 20% less content
|
||||||
|
|
||||||
|
**Recommendation**: Use speed 0.9-1.0 for optimal quality/cost balance
|
||||||
|
|
||||||
|
### 3.4 Strategy 4: Audio-Only Mode Skip
|
||||||
|
|
||||||
|
**For audio-only podcasts** (no video):
|
||||||
|
|
||||||
|
1. **Skip avatar generation** - Save $0.10 per speaker
|
||||||
|
2. **Skip video rendering** - Save $0.30 per scene
|
||||||
|
3. **Skip scene images** - Save $0.04-$0.10 per scene
|
||||||
|
|
||||||
|
**Estimated savings for 5-min, 5-scene audio podcast**:
|
||||||
|
| Component | Cost | Audio-Only Savings |
|
||||||
|
|-----------|------|---------------------|
|
||||||
|
| Avatar | $0.10 | $0.10 |
|
||||||
|
| Video (5 scenes) | $1.50 | $1.50 |
|
||||||
|
| Images (5 scenes) | $0.20-$0.50 | $0.20-$0.50 |
|
||||||
|
| **Total** | $1.80-$2.10 | **$1.80-$2.10** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Implementation Plan
|
||||||
|
|
||||||
|
### 4.1 Phase 1: User-Facing Controls (Frontend)
|
||||||
|
|
||||||
|
#### 4.1.1 Add "Audio Only" Toggle
|
||||||
|
- Location: `CreateModal.tsx` or `PodcastConfiguration.tsx`
|
||||||
|
- Options: `Audio Only` | `Video Only` | `Audio + Video`
|
||||||
|
- When enabled: Skip avatar, image, video generation
|
||||||
|
- Pass `audio_only: true` or `video_only: true` to backend
|
||||||
|
|
||||||
|
#### 4.1.2 Cost Preview Updates
|
||||||
|
- Show cost comparison based on selected mode
|
||||||
|
- Display potential savings for audio-only vs video
|
||||||
|
|
||||||
|
### 4.2 Phase 2: Script Editor UI (NEW - CRITICAL)
|
||||||
|
|
||||||
|
#### 4.2.1 Three Mode UI Strategy
|
||||||
|
|
||||||
|
The script editor needs to adapt based on the podcast mode:
|
||||||
|
|
||||||
|
| Mode | Script Editor UI | Available Actions |
|
||||||
|
|------|------------------|-------------------|
|
||||||
|
| **Audio Only** | Single audio-optimized script | Generate Audio only |
|
||||||
|
| **Video Only** | Current video script editor | Generate Audio + Image + Video |
|
||||||
|
| **Audio + Video** | Two tabs: "Audio Script" + "Video Script" | Full generation options |
|
||||||
|
|
||||||
|
#### 4.2.2 Implementation Details
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx`
|
||||||
|
|
||||||
|
**New Component Structure:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ScriptEditorProps {
|
||||||
|
// ... existing props
|
||||||
|
audioOnlyMode: boolean; // Audio-only podcast
|
||||||
|
videoOnlyMode: boolean; // Video-only podcast (current behavior)
|
||||||
|
audioScript?: Script; // Audio-optimized script (3-4 scenes, more lines)
|
||||||
|
videoScript?: Script; // Video-optimized script (current)
|
||||||
|
onAudioScriptChange?: (script: Script) => void;
|
||||||
|
onVideoScriptChange?: (script: Script) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Layout:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Script Editor [Audio] [Video] tabs (if both)
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Mode: Audio-Only │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Scene 1: Introduction (90s) [Edit]│ │
|
||||||
|
│ │ Host: Welcome to today's episode... │ │
|
||||||
|
│ │ Host: Today we're diving deep into... │ │
|
||||||
|
│ │ ... (6-10 lines per scene for audio) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Generate Audio] $0.04 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2.3 Tab Implementation for Audio + Video Mode
|
||||||
|
|
||||||
|
**When both Audio and Video are selected:**
|
||||||
|
|
||||||
|
1. Show two tabs in script editor:
|
||||||
|
- **Tab 1: "Audio Script"** - Audio-optimized (fewer scenes, more content)
|
||||||
|
- **Tab 2: "Video Script"** - Current video script (more scenes, visual)
|
||||||
|
|
||||||
|
2. Each tab has independent:
|
||||||
|
- Scene structure
|
||||||
|
- Edit capabilities
|
||||||
|
- Generation buttons
|
||||||
|
|
||||||
|
3. Generation actions differ by tab:
|
||||||
|
- Audio Tab: "Generate Audio" button only
|
||||||
|
- Video Tab: "Generate Audio" + "Generate Image" + "Generate Video"
|
||||||
|
|
||||||
|
#### 4.2.4 Backend Script Generation Updates
|
||||||
|
|
||||||
|
**Script generation endpoint changes:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In PodcastScriptRequest model
|
||||||
|
class PodcastScriptRequest(BaseModel):
|
||||||
|
# ... existing fields
|
||||||
|
audio_only: bool = False # Generate audio-optimized script
|
||||||
|
video_only: bool = False # Generate video-optimized script (current)
|
||||||
|
# If both False AND audio/video mode is "both", generate both scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prompt Selection Logic:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
if request.audio_only:
|
||||||
|
prompt = AUDIO_ONLY_PROMPT # 3-4 scenes, 6-10 lines/scene
|
||||||
|
elif request.video_only:
|
||||||
|
prompt = VIDEO_PROMPT # Current 5-6 scenes, 2-4 lines/scene
|
||||||
|
else:
|
||||||
|
# Generate both scripts with respective prompts
|
||||||
|
audio_prompt = AUDIO_ONLY_PROMPT
|
||||||
|
video_prompt = VIDEO_PROMPT
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Phase 3: Backend Script Generation (AI Prompts)
|
||||||
|
|
||||||
|
#### 4.2.1 Two-Tier Script Generation Strategy
|
||||||
|
|
||||||
|
**Current Behavior (Video Podcast):**
|
||||||
|
- Existing prompt in `backend/api/podcast/handlers/script.py` (lines 125-151)
|
||||||
|
- Optimized for video with shorter scenes (2-4 lines per scene)
|
||||||
|
- 5-6 scenes max for visual storytelling
|
||||||
|
- Less content per scene to match video duration
|
||||||
|
|
||||||
|
**New Audio-Only Mode:**
|
||||||
|
- New prompt optimized for audio-only content
|
||||||
|
- More content-dense, information-rich
|
||||||
|
- Fewer scenes with MORE content per scene
|
||||||
|
- Maximizes use of research data
|
||||||
|
- Reduces API calls while delivering more value
|
||||||
|
|
||||||
|
#### 4.2.2 Audio-Only Script Prompt
|
||||||
|
|
||||||
|
**Location:** `backend/api/podcast/handlers/script.py`
|
||||||
|
|
||||||
|
**New Prompt for Audio-Only:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
AUDIO_ONLY_PROMPT = """Create a DEEP, content-rich podcast script optimized for AUDIO-ONLY delivery.
|
||||||
|
|
||||||
|
{f"RESEARCH DATA (Use extensively - this is audio only, more content is better): {research_context[:3000]}" if research_context else "No research available - generate general content"}
|
||||||
|
|
||||||
|
{f"BIBLE: {bible_context[:1500]}" if bible_context else ""}
|
||||||
|
{f"{analysis_context}" if analysis_context else ""}
|
||||||
|
|
||||||
|
Topic: "{request.idea}"
|
||||||
|
Duration: {request.duration_minutes} min | Speakers: {request.speakers}
|
||||||
|
MODE: AUDIO-ONLY (no video constraints - maximize content density)
|
||||||
|
|
||||||
|
COST OPTIMIZATION (Audio-Only):
|
||||||
|
- 3-4 scenes MAX for entire episode (fewer scenes = fewer API calls)
|
||||||
|
- EACH scene should have 6-10 LINES (more content per scene)
|
||||||
|
- Each line: 3-5 sentences, information-dense
|
||||||
|
- Include: facts, statistics, examples, insights from research
|
||||||
|
- NO visual descriptions needed (save tokens for content)
|
||||||
|
- Make every line deliver unique value
|
||||||
|
|
||||||
|
STRUCTURE per scene:
|
||||||
|
- scene_id: string
|
||||||
|
- title: short descriptive title
|
||||||
|
- duration: seconds (target {request.duration_minutes*60 // 3}-{request.duration_minutes*60 // 4} per scene)
|
||||||
|
- emotion: neutral|happy|excited|serious|curious|confident
|
||||||
|
- lines: array of {{speaker, text, emphasis}}
|
||||||
|
- speaker: "Host" or "Guest"
|
||||||
|
- text: 3-5 sentences, rich with facts/insights
|
||||||
|
- emphasis: true|false for important points
|
||||||
|
|
||||||
|
Return JSON with scenes array.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Differences:**
|
||||||
|
|
||||||
|
| Aspect | Video (Current) | Audio-Only (New) |
|
||||||
|
|--------|------------------|------------------|
|
||||||
|
| Scenes | 5-6 | 3-4 |
|
||||||
|
| Lines/Scene | 2-4 | 6-10 |
|
||||||
|
| Sentences/Line | 1-3 | 3-5 |
|
||||||
|
| Research Usage | 1,200 chars | 3,000 chars |
|
||||||
|
| Focus | Visual storytelling | Content density |
|
||||||
|
| API Calls | More (lower cost/scene) | Fewer (higher cost/scene) |
|
||||||
|
|
||||||
|
#### 4.2.3 Implementation Details
|
||||||
|
|
||||||
|
**File:** `backend/api/podcast/handlers/script.py`
|
||||||
|
|
||||||
|
1. Add `audio_only: bool` parameter to `PodcastScriptRequest`
|
||||||
|
2. Conditionally select prompt based on `audio_only` flag
|
||||||
|
3. For audio-only:
|
||||||
|
- Use expanded research context (3,000 chars vs 1,200)
|
||||||
|
- Request more lines per scene
|
||||||
|
- Fewer total scenes
|
||||||
|
- More content per line
|
||||||
|
|
||||||
|
### 4.4 Phase 4: Backend Optimizations
|
||||||
|
|
||||||
|
#### 4.3.1 Smart Scene Batching
|
||||||
|
- File: `backend/api/podcast/handlers/audio.py`
|
||||||
|
- Logic: Group scenes with total chars < 9000
|
||||||
|
- Add pause markers between scenes
|
||||||
|
|
||||||
|
#### 4.3.2 Audio-Only Flag in Project
|
||||||
|
- Model: Add `audio_only: bool` to project settings
|
||||||
|
- Skip: Avatar generation, image generation, video rendering
|
||||||
|
|
||||||
|
### 4.4 Phase 4: Cost Calculation Updates
|
||||||
|
|
||||||
|
#### 4.4.1 Update Frontend Estimation
|
||||||
|
- File: `frontend/src/services/podcastApi.ts`
|
||||||
|
- Formula updates:
|
||||||
|
```typescript
|
||||||
|
const estimatedApiCalls = Math.ceil(totalChars / 9500);
|
||||||
|
const ttsCost = estimatedApiCalls * 0.05;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Technical Details
|
||||||
|
|
||||||
|
### 5.1 Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `frontend/src/components/PodcastMaker/types.ts` | Add `audio_only`, `video_only`, `podcast_mode` to project settings |
|
||||||
|
| `frontend/src/components/PodcastMaker/CreateModal.tsx` | Add mode toggle (Audio/Video/Both) |
|
||||||
|
| `frontend/src/services/podcastApi.ts` | Update cost estimation for each mode |
|
||||||
|
| `frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx` | Add tab support for Audio + Video mode |
|
||||||
|
| `frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx` | Conditional action buttons per mode |
|
||||||
|
| `backend/api/podcast/models.py` | Add `audio_only`, `video_only` fields to request model |
|
||||||
|
| `backend/api/podcast/handlers/script.py` | Add audio-only + video-only prompts, return both scripts when needed |
|
||||||
|
| `backend/api/podcast/handlers/audio.py` | Implement smart batching |
|
||||||
|
|
||||||
|
### 5.2 API Endpoints
|
||||||
|
|
||||||
|
```python
|
||||||
|
# PodcastScriptRequest model changes
|
||||||
|
class PodcastScriptRequest(BaseModel):
|
||||||
|
idea: str
|
||||||
|
duration_minutes: int
|
||||||
|
speakers: int
|
||||||
|
research: Optional[Dict] = None
|
||||||
|
bible: Optional[Dict] = None
|
||||||
|
analysis: Optional[Dict] = None
|
||||||
|
outline: Optional[Dict] = None
|
||||||
|
# NEW FIELDS:
|
||||||
|
audio_only: bool = False # Generate audio-optimized script
|
||||||
|
video_only: bool = False # Generate video-optimized script (current)
|
||||||
|
# Both False = generate both scripts for audio+video mode
|
||||||
|
|
||||||
|
# Response includes both scripts when needed
|
||||||
|
class PodcastScriptResponse(BaseModel):
|
||||||
|
audio_script: Optional[Script] = None # Audio-optimized
|
||||||
|
video_script: Optional[Script] = None # Video-optimized
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Database Schema
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In PodcastProject model
|
||||||
|
audio_only: bool = False
|
||||||
|
scene_length_target: int = 60 # seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Experience
|
||||||
|
|
||||||
|
### 6.1 Create Phase - Mode Toggle
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 🎙️ Create New Podcast │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Duration: [5] minutes Speakers: [1] [2] │
|
||||||
|
│ │
|
||||||
|
│ Podcast Mode: │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Audio Only │ │ Video Only │ │ Audio+Video │ │
|
||||||
|
│ │ ($0.22) │ │ ($2.02) │ │ ($2.24) │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Est. Cost: $0.22 (audio only) vs $2.02 (with video) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Script Editor - Audio Only Mode
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Script Editor │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 📻 Audio-Only Mode │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Scene 1: Introduction (90s) [Edit]│
|
||||||
|
│ │ Host: Welcome to today's episode on AI... │
|
||||||
|
│ │ Host: Today we're diving deep into how AI... │
|
||||||
|
│ │ Host: I'm excited to share three key insights... │
|
||||||
|
│ │ ... (6-10 lines for audio) │
|
||||||
|
│ │ │
|
||||||
|
│ │ Scene 2: Main Topic (120s) [Edit]│
|
||||||
|
│ │ ... │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Generate Audio] $0.04 [Generate Image] Disabled │
|
||||||
|
│ [Generate Video] Disabled │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Script Editor - Video Only Mode (Current)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Script Editor │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 🎬 Video Mode │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Scene 1: Intro (30s) [Image] [Audio] [V] │
|
||||||
|
│ │ Scene 2: Hook (30s) [Image] [Audio] [V] │
|
||||||
|
│ │ Scene 3: Content (45s) [Image] [Audio] [V] │
|
||||||
|
│ │ Scene 4: Example (30s) [Image] [Audio] [V] │
|
||||||
|
│ │ Scene 5: CTA (15s) [Image] [Audio] [V] │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Generate Audio] $0.19 [Generate Image] $0.10 │
|
||||||
|
│ [Generate Video] $1.50 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Script Editor - Audio + Video Mode (Both)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Script Editor [Audio] [Video] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ [Audio] Tab | [Video] Tab │ │
|
||||||
|
│ ├─────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ Audio Script: │ │
|
||||||
|
│ │ Scene 1: Intro (90s) - 8 lines │ │
|
||||||
|
│ │ Scene 2: Deep Dive (120s) - 10 lines │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [Generate Audio] $0.04 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
OR
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Script Editor [Audio] [Video] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ [Audio] Tab | [Video] Tab │ │
|
||||||
|
│ ├─────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ Video Script: │ │
|
||||||
|
│ │ Scene 1: Intro (30s) [Img] [Aud] [Vid] │ │
|
||||||
|
│ │ Scene 2: Hook (30s) [Img] [Aud] [Vid] │ │
|
||||||
|
│ │ Scene 3: Content (45s) [Img] [Aud] [Vid] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [Generate Audio] [Generate Image] [Generate Video] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Cost Comparison UI
|
||||||
|
|
||||||
|
| Mode | Scenes | Lines/Scene | TTS Cost | Video Cost | Total |
|
||||||
|
|------|--------|-------------|----------|------------|-------|
|
||||||
|
| Audio Only | 3-4 | 6-10 | $0.19 | $0 | **$0.22** |
|
||||||
|
| Video Only | 5-6 | 2-4 | $0.19 | $1.50 | **$1.69** |
|
||||||
|
| Audio+Video | 3-4 + 5-6 | varies | $0.19 | $1.50 | **$1.72** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Testing Plan
|
||||||
|
|
||||||
|
### 7.1 Unit Tests
|
||||||
|
|
||||||
|
1. Test character count calculation
|
||||||
|
2. Test scene batching logic (under 10k chars)
|
||||||
|
3. Test cost estimation accuracy
|
||||||
|
|
||||||
|
### 7.2 Integration Tests
|
||||||
|
|
||||||
|
1. Generate audio for 10-minute podcast with 5 scenes
|
||||||
|
2. Verify all scenes generate correctly
|
||||||
|
3. Verify cost tracking in database
|
||||||
|
|
||||||
|
### 7.3 Performance Tests
|
||||||
|
|
||||||
|
1. Measure time for batched vs sequential API calls
|
||||||
|
2. Verify no timeout issues with longer text
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Success Metrics
|
||||||
|
|
||||||
|
| Metric | Target | Current |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| API calls per 5-min podcast | 5 | 7 |
|
||||||
|
| Cost per 5-min audio podcast | $0.22 | $0.22 + video |
|
||||||
|
| User-visible savings | 50%+ | N/A |
|
||||||
|
| Scene length default | 60s | 45s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Appendix: Related Files
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `backend/services/llm_providers/main_audio_generation.py` - TTS cost calculation
|
||||||
|
- `backend/api/podcast/handlers/audio.py` - Audio generation endpoint
|
||||||
|
- `backend/api/podcast/handlers/script.py` - Script generation
|
||||||
|
- `backend/services/subscription/pricing_service.py` - Pricing configuration
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `frontend/src/services/podcastApi.ts` - Cost estimation
|
||||||
|
- `frontend/src/components/PodcastMaker/CreateModal.tsx` - Create UI
|
||||||
|
- `frontend/src/components/PodcastMaker/types.ts` - Type definitions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Document History
|
||||||
|
|
||||||
|
| Version | Date | Author | Changes |
|
||||||
|
|---------|------|--------|---------|
|
||||||
|
| 1.0 | 2026-04-08 | ALwrity Team | Initial document creation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This document serves as the reference for audio-only podcast optimization in ALwrity Podcast Maker.*
|
||||||
Reference in New Issue
Block a user