diff --git a/.gitignore b/.gitignore
index d34a2410..610ce41b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,10 @@ __pycache__/
*.db
*.sqlite*
+nul
+LICENSE
+CHANGELOG.md
+
.trae/
.trae
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..1cc9a28b
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/ToBeMigrated/lib/ai_marketing_tools/ai_backlinker/README.md b/ToBeMigrated/lib/ai_marketing_tools/ai_backlinker/README.md
new file mode 100644
index 00000000..e69de29b
diff --git a/_session_backup/App.tsx b/_session_backup/App.tsx
new file mode 100644
index 00000000..a8f10a7c
--- /dev/null
+++ b/_session_backup/App.tsx
@@ -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 (
+
+
+
+
+ Chat Unavailable
+
+
+ CopilotKit encountered an error. The app continues to work with manual controls.
+
+
+ }
+ >
+
+ {children}
+
+
+
+ );
+ }
+
+ return (
+
+
+ {children}
+
+ );
+};
+
+// 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 (
+
+ );
+ }
+
+ // 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 (
+
+
+
+ {subscriptionLoading ? 'Checking subscription...' : 'Preparing your workspace...'}
+
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+
+ Error
+
+
+ {error}
+
+
+ );
+ }
+
+ // 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 (
+
+
+
+ Checking subscription...
+
+
+ );
+ }
+
+ // 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 ;
+ }
+
+ // Onboarding not complete and no subscription data
+ // If subscription check is still loading, show loading state
+ if (subscriptionLoading) {
+ return (
+
+
+
+ Checking subscription...
+
+
+ );
+ }
+
+ // 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 ;
+ }
+
+ // 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 ;
+ }
+ // 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 ;
+ }
+
+ // 5. Has subscription AND completed onboarding → Dashboard
+ console.log('InitialRouteHandler: All set (subscription + onboarding) → Dashboard');
+ return ;
+};
+
+// Root route that chooses Landing (signed out) or InitialRouteHandler (signed in)
+const RootRoute: React.FC = () => {
+ const { isSignedIn } = useAuth();
+ if (isSignedIn) {
+ return ;
+ }
+ return ;
+};
+
+// 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 (
+
+
+
+ Connecting to ALwrity...
+
+
+ );
+ }
+
+
+ // 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 (
+
+
+ Missing Clerk Publishable Key
+
+
+ Please add REACT_APP_CLERK_PUBLISHABLE_KEY to your .env file
+
+
+ );
+ }
+
+ // Render app with or without CopilotKit based on whether we have a key
+ const renderApp = () => {
+ return (
+
+
+
+
+
+ } />
+
+
+
+ }
+ />
+ {/* Error Boundary Testing - Development Only */}
+ {process.env.NODE_ENV === 'development' && (
+ } />
+ )}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+ );
+ };
+
+ return (
+ {
+ // Custom error handler - send to analytics/monitoring
+ console.error('Global error caught:', { error, errorInfo });
+ // TODO: Send to error tracking service (Sentry, LogRocket, etc.)
+ }}
+ >
+
+
+
+ {renderApp()}
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/_session_backup/ResearchSummary.tsx b/_session_backup/ResearchSummary.tsx
new file mode 100644
index 00000000..39176d56
--- /dev/null
+++ b/_session_backup/ResearchSummary.tsx
@@ -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 = ({
+ 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, '$1');
+ // Handle lists
+ if (processedLine.trim().startsWith('- ') || processedLine.trim().startsWith('* ')) {
+ return ;
+ }
+ // Handle headers - make them smaller
+ if (processedLine.startsWith('### ')) {
+ return {processedLine.substring(4)};
+ }
+ if (processedLine.startsWith('## ')) {
+ return {processedLine.substring(3)};
+ }
+ // Paragraphs - compact spacing
+ return processedLine.trim() ? : null;
+ });
+ }, []);
+
+ return (
+
+
+
+
+
+
+ Research Summary
+
+
+ {/* Research Metadata - Moved alongside title */}
+
+ {research.searchQueries && research.searchQueries.length > 0 && (
+ }
+ 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 && (
+
+ )}
+ {research.sourceCount !== undefined && (
+
+ )}
+ {research.cost !== undefined && (
+ }
+ 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)",
+ }}
+ />
+ )}
+
+
+
+ }
+ tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
+ >
+ Generate Script
+
+
+
+
+ {/* Main Summary */}
+ {research.summary && (
+
+
+
+ Executive Summary
+
+
+ {renderMarkdown(research.summary)}
+
+
+ )}
+
+ {/* Deep Insights */}
+ {(research.keyInsights && research.keyInsights.length > 0) ? (
+
+
+
+ Deep Insights
+
+
+ {research.keyInsights.map((insight: ResearchInsight, idx: number) => (
+
+
+
+ {insight.title}
+
+ {insight.source_indices && insight.source_indices.length > 0 && (
+
+ {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 (
+
+ Source {sIdx}
+
+ {sourceUrl}
+
+ ) : `Source ${sIdx}`}
+ arrow
+ placement="top"
+ >
+ 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)",
+ } : {},
+ }}
+ />
+
+ );
+ })}
+
+ )}
+
+
+ {renderMarkdown(insight.content)}
+
+
+ ))}
+
+
+ ) : (
+ /* Fallback if keyInsights is missing but we have summary paragraphs */
+ research.summary && research.summary.length > 500 && !research.keyInsights && (
+
+
+
+ Additional Insights
+
+
+
+ {/* Render parts of summary that might contain insights if structured data is missing */}
+ {renderMarkdown(research.summary.split('\n\n').slice(1).join('\n\n'))}
+
+
+
+ )
+ )}
+
+ {/* Expert Quotes Section */}
+ {research.expertQuotes && research.expertQuotes.length > 0 && (
+
+
+
+ Expert Quotes ({research.expertQuotes.length})
+
+
+ {research.expertQuotes.map((eq, idx) => (
+
+
+
+
+
+ “{eq.quote}”
+
+ {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 (
+
+
+ Source {eq.source_index}
+
+ {sourceUrl}
+
+ ) : `Source ${eq.source_index}`} arrow placement="top">
+ 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)",
+ } : {},
+ }}
+ />
+
+
+ );
+ })()}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Search Queries Used */}
+ {research.searchQueries && research.searchQueries.length > 0 && (
+
+
+ Search Queries Used
+
+
+ {research.searchQueries.map((query, idx) => (
+
+ ))}
+
+
+ )}
+
+
+ {research.factCards.length > 0 && (
+ <>
+
+
+
+ Research Sources & Facts ({research.factCards.length})
+
+
+ Click to expand • Hover to see source
+
+
+
+ {research.factCards.map((fact) => (
+
+ ))}
+
+ >
+ )}
+
+ {/* Listener CTA Section */}
+ {research.listenerCta && research.listenerCta.length > 0 && (
+ <>
+
+
+
+
+ Listener Call-to-Action Ideas ({research.listenerCta.length})
+
+
+ {research.listenerCta.map((cta, idx) => (
+
+
+
+ {cta}
+
+
+ ))}
+
+
+ >
+ )}
+
+ {/* Mapped Angles Section */}
+ {research.mappedAngles && research.mappedAngles.length > 0 && (
+ <>
+
+
+
+
+ Content Angles ({research.mappedAngles.length})
+
+
+ {research.mappedAngles.map((angle, idx) => (
+
+
+
+ {angle.title}
+
+ {angle.mappedFactIds && angle.mappedFactIds.length > 0 && (
+
+ {angle.mappedFactIds.slice(0, 4).map((fid: string) => (
+
+ ))}
+ {angle.mappedFactIds.length > 4 && (
+
+ )}
+
+ )}
+
+
+ {angle.why}
+
+
+ ))}
+
+
+ >
+ )}
+
+
+ );
+};
+
diff --git a/_session_backup/SceneEditor.tsx b/_session_backup/SceneEditor.tsx
new file mode 100644
index 00000000..3ce71089
--- /dev/null
+++ b/_session_backup/SceneEditor.tsx
@@ -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;
+ 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 = ({
+ 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("");
+ const [imageGenerationProgress, setImageGenerationProgress] = useState(0);
+ const [audioBlobUrl, setAudioBlobUrl] = useState(null);
+ const [imageBlobUrl, setImageBlobUrl] = useState(null);
+ const [imageLoading, setImageLoading] = useState(false);
+ const [showRegenerateModal, setShowRegenerateModal] = useState(false);
+ const [showAudioModal, setShowAudioModal] = useState(false);
+ const [audioSettings, setAudioSettings] = useState({
+ 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 (
+
+
+
+
+
+
+ {scene.title}
+
+
+ : }
+ 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)",
+ }}
+ />
+
+ Duration: {scene.duration}s
+
+
+
+
+
+ ) : generating ? (
+
+ ) : (
+
+ )
+ }
+ 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"}
+
+ handleGenerateImage()}
+ disabled={generatingImage}
+ loading={generatingImage}
+ startIcon={
+ hasImage && !generatingImage ? (
+
+ ) : generatingImage ? (
+
+ ) : (
+
+ )
+ }
+ 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"}
+
+
+
+ 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",
+ },
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ {scene.lines.map((line) => (
+
+ ))}
+
+
+ {scene.audioUrl && (
+ <>
+
+
+
+
+
+ {hasAudio ? "Audio Generated" : "Loading Audio..."}
+
+
+ {hasAudio && audioBlobUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+ >
+ )}
+
+ {/* Image Generation Progress - Show when generating */}
+ {generatingImage && (
+ <>
+
+
+
+
+
+ Generating Image...
+
+
+
+ {/* Progress Bar */}
+
+
+
+ {imageGenerationProgress}%
+
+
+
+ {/* Status Message */}
+ {imageGenerationStatus && (
+
+ {imageGenerationStatus}
+
+ )}
+
+ {/* Spinner */}
+
+
+
+
+ >
+ )}
+
+ {/* Generated Image Display - Show when image exists and not generating */}
+ {scene.imageUrl && !generatingImage && (
+ <>
+
+
+
+
+
+ {imageBlobUrl && !imageLoading ? "Image Generated" : "Loading Image..."}
+
+
+ {imageBlobUrl && !imageLoading ? (
+
+ {
+ console.error('[SceneEditor] Image failed to load:', {
+ src: e.currentTarget.src,
+ imageUrl: scene.imageUrl,
+ imageBlobUrl,
+ });
+ }}
+ onLoad={() => {
+ console.log('[SceneEditor] Image loaded successfully');
+ }}
+ />
+
+ ) : (
+
+
+
+ )}
+
+ >
+ )}
+
+
+ {/* Image Regeneration Modal */}
+ 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}
+ />
+
+ setShowAudioModal(false)}
+ onRegenerate={handleAudioRegenerate}
+ initialSettings={audioSettings}
+ isGenerating={generating}
+ />
+
+ );
+};
+
diff --git a/_session_backup/ScriptEditor.tsx b/_session_backup/ScriptEditor.tsx
new file mode 100644
index 00000000..98d4cd05
--- /dev/null
+++ b/_session_backup/ScriptEditor.tsx
@@ -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 = ({
+ projectId,
+ idea,
+ research,
+ rawResearch,
+ knobs,
+ speakers,
+ durationMinutes,
+ script: initialScript,
+ onScriptChange,
+ onBackToResearch,
+ onProceedToRendering,
+ onError,
+ avatarUrl,
+ analysis,
+ outline,
+}) => {
+ const [script, setScript] = useState