Podcast Maker: Fix progress modals, research JSON, header stepper, voice/podcastMode chips

This commit is contained in:
ajaysi
2026-04-19 13:16:59 +05:30
parent ff61708e29
commit e704aa7d87
61 changed files with 7965 additions and 368 deletions

View File

@@ -66,15 +66,15 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
const [showAudioModal, setShowAudioModal] = useState(false);
const [showImagePreview, setShowImagePreview] = useState(false);
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
voiceId: "Wise_Woman",
customVoiceId: undefined,
speed: 1.0,
voiceId: knobs.voice_id || "Wise_Woman",
customVoiceId: knobs.custom_voice_id || undefined,
speed: knobs.voice_speed ?? 1.0,
volume: 1.0,
pitch: 0.0,
emotion: scene.emotion || "neutral",
emotion: scene.emotion || knobs.voice_emotion || "neutral",
englishNormalization: true,
sampleRate: 24000,
bitrate: 64000,
sampleRate: knobs.sample_rate || 24000,
bitrate: knobs.bitrate === 'hd' ? 128000 : 64000,
channel: "1",
format: "mp3",
languageBoost: "auto",

View File

@@ -0,0 +1,50 @@
import React from "react";
import { Box, Typography, Stack, Chip } from "@mui/material";
import {
EditNote as EditNoteIcon,
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
} from "@mui/icons-material";
import { Scene } from "../../types";
interface SceneEditorHeaderProps {
scene: Scene;
}
export const SceneEditorHeader: React.FC<SceneEditorHeaderProps> = ({ scene }) => {
return (
<>
<Typography
variant="h6"
sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 1, color: "#0f172a", fontWeight: 600 }}
>
<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>
</>
);
};

View File

@@ -0,0 +1,29 @@
import React from "react";
import { Box, Divider, Stack, Typography, CircularProgress } from "@mui/material";
import { VolumeUp as VolumeUpIcon } from "@mui/icons-material";
interface SceneEditorMediaPanelProps {
hasAudio: boolean;
audioBlobUrl?: string | null;
isGenerating?: boolean;
}
// Minimal media panel wrapper extracted for refactor hygiene
export const SceneEditorMediaPanel: React.FC<SceneEditorMediaPanelProps> = ({ hasAudio, audioBlobUrl, isGenerating }) => {
return (
<Box sx={{ mt: 1, p: 2, borderRadius: 2, border: "1px solid rgba(0,0,0,0.08)", background: "#fff" }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<VolumeUpIcon sx={{ color: hasAudio ? "#059669" : "#d97706" }} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: hasAudio ? "#059669" : "#d97706" }}>
{hasAudio ? "Audio Generated" : "Loading Audio..."}
</Typography>
</Stack>
{audioBlobUrl ? (
<audio controls src={audioBlobUrl} style={{ width: "100%" }} />
) : isGenerating ? (
<CircularProgress size={20} />
) : null}
<Divider sx={{ mt: 2 }} />
</Box>
);
};

View File

@@ -1,6 +1,6 @@
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 { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, Collapse, IconButton, Divider, Chip, Tooltip } from "@mui/material";
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon, Download as DownloadIcon, Refresh as RefreshIcon, Mic as MicIcon } from "@mui/icons-material";
import { Script, Knobs, Scene } from "../types";
import { BlogResearchResponse } from "../../../services/blogWriterApi";
import { podcastApi } from "../../../services/podcastApi";
@@ -300,6 +300,27 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
>
<EditNoteIcon sx={{ fontSize: "2rem" }} />
Script Editor
{knobs.voice_id && (() => {
const vid = knobs.voice_id;
const isCustom = Boolean(vid && !vid.startsWith("builtin:") && !["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman", "Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man", "Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess", "Sweet_Girl_2", "Exuberant_Girl"].includes(vid));
const vName = isCustom ? "My Voice Clone" : (vid === "Wise_Woman" ? "Wise Woman" : vid === "Friendly_Person" ? "Friendly Person" : vid === "Deep_Voice_Man" ? "Deep Voice Man" : vid?.replace(/_/g, " ") || "Default");
return (
<Chip
icon={<MicIcon sx={{ fontSize: "14px !important" }} />}
label={`Active Voice: ${vName}`}
size="small"
sx={{
ml: 2,
background: isCustom ? "rgba(16, 185, 129, 0.1)" : "rgba(99, 102, 241, 0.1)",
color: isCustom ? "#10b981" : "#6366f1",
border: `1px solid ${isCustom ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)"}`,
'& .MuiChip-icon': { color: isCustom ? "#10b981" : "#6366f1" },
fontWeight: 600,
fontSize: "0.75rem",
}}
/>
);
})()}
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
Review and refine your podcast script before rendering

View File

@@ -0,0 +1,550 @@
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
import { Script, Knobs, Scene, PodcastMode } from "../types";
import { podcastApi } from "../../../services/podcastApi";
interface ScriptEditorContextType {
// State
script: Script | null;
loading: boolean;
error: string | null;
podcastMode: PodcastMode;
approvingSceneId: string | null;
generatingAudioId: string | null;
showScriptFormatInfo: boolean;
combiningAudio: boolean;
scriptTab: "audio" | "video";
combinedAudioResult: { url: string; filename: string; duration: number; sceneCount: number } | null;
generatingBatchAudio: boolean;
batchAudioProgress: { completed: number; total: number } | null;
generatingChartId: string | null; // B-roll: generating chart preview
// Computed
activeScript: Script | null;
allApproved: boolean | null;
approvedCount: number;
totalScenes: number;
allScenesHaveAudio: boolean | null;
scenesWithAudio: number;
allScenesHaveAudioAndImages: boolean | null;
needsAudioGeneration: boolean | null;
scenesWithCharts: number; // B-roll: count of scenes with chart data
// Setters for UI state
setScript: React.Dispatch<React.SetStateAction<Script | null>>;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
setError: React.Dispatch<React.SetStateAction<string | null>>;
setApprovingSceneId: React.Dispatch<React.SetStateAction<string | null>>;
setGeneratingAudioId: React.Dispatch<React.SetStateAction<string | null>>;
setShowScriptFormatInfo: React.Dispatch<React.SetStateAction<boolean>>;
setCombiningAudio: React.Dispatch<React.SetStateAction<boolean>>;
setScriptTab: React.Dispatch<React.SetStateAction<"audio" | "video">>;
setCombinedAudioResult: React.Dispatch<React.SetStateAction<{ url: string; filename: string; duration: number; sceneCount: number } | null>>;
setGeneratingBatchAudio: React.Dispatch<React.SetStateAction<boolean>>;
setBatchAudioProgress: React.Dispatch<React.SetStateAction<{ completed: number; total: number } | null>>;
setGeneratingChartId: React.Dispatch<React.SetStateAction<string | null>>;
// Actions
updateScene: (updated: Scene) => void;
approveScene: (sceneId: string) => Promise<void>;
deleteScene: (sceneId: string) => void;
generateAllAudio: () => Promise<void>;
combineAudio: () => Promise<void>;
emitScriptChange: (next: Script) => void;
// B-roll actions
generateChartPreviews: () => Promise<void>;
regenerateChart: (sceneId: string) => Promise<void>;
removeChart: (sceneId: string) => void;
}
const ScriptEditorContext = createContext<ScriptEditorContextType | undefined>(undefined);
interface ScriptEditorProviderProps {
children: ReactNode;
projectId: string;
idea: string;
rawResearch: any;
knobs: Knobs;
speakers: number;
durationMinutes: number;
initialScript: Script | null;
initialAudioScript?: Script | null;
initialVideoScript?: Script | null;
podcastMode?: PodcastMode;
analysis?: any;
outline?: any;
onScriptChange: (script: Script) => void;
onError: (message: string) => void;
}
export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
children,
projectId,
idea,
rawResearch,
knobs,
speakers,
durationMinutes,
initialScript,
initialAudioScript,
initialVideoScript,
podcastMode = "video_only",
analysis,
outline,
onScriptChange,
onError,
}) => {
// Core state
const [script, setScript] = useState<Script | null>(initialScript);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// UI state
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(false);
const [combiningAudio, setCombiningAudio] = useState(false);
const [scriptTab, setScriptTab] = useState<"audio" | "video">("video");
const [combinedAudioResult, setCombinedAudioResult] = useState<{
url: string;
filename: string;
duration: number;
sceneCount: number;
} | null>(null);
const [generatingBatchAudio, setGeneratingBatchAudio] = useState(false);
const [batchAudioProgress, setBatchAudioProgress] = useState<{ completed: number; total: number } | null>(null);
const [generatingChartId, setGeneratingChartId] = useState<string | null>(null);
// Emit script changes to parent (deferred to avoid setState during render)
const emitScriptChange = useCallback(
(next: Script) => {
Promise.resolve().then(() => onScriptChange(next));
},
[onScriptChange]
);
// Determine which script to display based on mode and tab
const getActiveScript = (): Script | null => {
const currentScript = script || null;
if (podcastMode === "audio_only") {
if (currentScript?.audioScript) {
return { scenes: currentScript.audioScript };
}
return initialAudioScript || null;
}
if (podcastMode === "video_only") {
return currentScript || initialVideoScript || null;
}
if (podcastMode === "audio_video") {
if (scriptTab === "audio") {
if (currentScript?.audioScript) {
return { scenes: currentScript.audioScript };
}
return initialAudioScript || null;
} else {
if (currentScript?.videoScript) {
return { scenes: currentScript.videoScript };
}
return currentScript || initialVideoScript || null;
}
}
return currentScript || initialVideoScript || null;
};
const activeScript = getActiveScript();
// Computed values
const allApproved = activeScript && activeScript.scenes.every((s) => s.approved);
const approvedCount = activeScript ? activeScript.scenes.filter((s) => s.approved).length : 0;
const totalScenes = activeScript ? activeScript.scenes.length : 0;
const allScenesHaveAudio = activeScript && activeScript.scenes.every((s) => s.audioUrl);
const scenesWithAudio = activeScript ? activeScript.scenes.filter((s) => s.audioUrl).length : 0;
const allScenesHaveAudioAndImages = activeScript && (
podcastMode === "audio_only"
? activeScript.scenes.every((s) => s.audioUrl)
: activeScript.scenes.every((s) => s.audioUrl && s.imageUrl)
);
const needsAudioGeneration = activeScript && !allScenesHaveAudio && activeScript.scenes.some((s) => !s.audioUrl);
// B-roll computed
const scenesWithCharts = activeScript ? activeScript.scenes.filter((s) => s.chart_data && Object.keys(s.chart_data).length > 0).length : 0;
// Sync with parent state
useEffect(() => {
if (initialScript) {
setScript(initialScript);
}
}, [initialScript]);
// Generate script effect - only if not already generated by parent
// This prevents duplicate API calls when both parent workflow and this component try to generate
useEffect(() => {
// Skip if parent already provided script via props
if (script || initialScript) {
return;
}
if (!rawResearch) {
return;
}
// Skip if podcastMode is audio_only (script should be passed from parent for audio_only)
// Parent workflow already generates the script, we just display it here
if (podcastMode === "audio_only") {
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) => {
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 });
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 (!activeScript) return;
if (activeScript.scenes.length <= 1) {
onError("Cannot delete the last scene. At least one scene is required.");
return;
}
const sceneToDelete = activeScript.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;
const updatedScenes = activeScript.scenes.filter(s => s.id !== sceneId);
const updatedScript = { ...activeScript, scenes: updatedScenes };
emitScriptChange(updatedScript);
setScript(updatedScript);
}, [activeScript, emitScriptChange, onError]);
const generateAllAudio = useCallback(async () => {
if (!activeScript || !projectId || !knobs) return;
const scenesNeedingAudio = activeScript.scenes.filter((s) => !s.audioUrl);
if (scenesNeedingAudio.length === 0) {
onError("All scenes already have audio generated.");
return;
}
try {
setGeneratingBatchAudio(true);
setBatchAudioProgress({ completed: 0, total: scenesNeedingAudio.length });
const sceneData = scenesNeedingAudio.map((scene) => ({
id: scene.id,
title: scene.title,
lines: scene.lines.map((line) => ({ text: line.text })),
}));
const result = await podcastApi.generateBatchAudio({
scenes: sceneData,
voiceId: knobs.voice_id,
customVoiceId: knobs.custom_voice_id,
speed: knobs.voice_speed,
emotion: knobs.voice_emotion,
englishNormalization: true,
});
const updatedScenes = activeScript.scenes.map((scene) => {
const batchResult = result.results.find((r: any) => r.sceneId === scene.id);
if (batchResult) {
return { ...scene, audioUrl: batchResult.audioUrl };
}
return scene;
});
await emitScriptChange({ ...activeScript, scenes: updatedScenes });
setBatchAudioProgress({ completed: scenesNeedingAudio.length, total: scenesNeedingAudio.length });
} catch (error: any) {
console.error("Batch audio generation failed:", error);
onError(`Failed to generate audio: ${error.message || error}`);
} finally {
setGeneratingBatchAudio(false);
setBatchAudioProgress(null);
}
}, [activeScript, projectId, knobs, emitScriptChange, onError]);
const combineAudio = useCallback(async () => {
if (!activeScript || !projectId) return;
try {
setCombiningAudio(true);
const sceneIds: string[] = [];
const sceneAudioUrls: string[] = [];
activeScript.scenes.forEach((scene) => {
if (scene.audioUrl) {
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,
});
setCombinedAudioResult({
url: result.combined_audio_url,
filename: result.combined_audio_filename,
duration: result.total_duration,
sceneCount: result.scene_count,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to combine audio";
onError(`Failed to combine audio: ${message}`);
} finally {
setCombiningAudio(false);
}
}, [activeScript, projectId, onError]);
// =====================
// B-Roll Actions
// =====================
const generateChartPreviews = useCallback(async () => {
if (!activeScript) return;
const scenesWithData = activeScript.scenes.filter(
(s) => s.chart_data && Object.keys(s.chart_data).length > 0
);
if (scenesWithData.length === 0) {
onError("No scenes have chart data to generate previews.");
return;
}
try {
setGeneratingChartId("all");
const updatedScenes = await Promise.all(
activeScript.scenes.map(async (scene) => {
if (!scene.chart_data || Object.keys(scene.chart_data).length === 0) {
return scene;
}
try {
const result = await podcastApi.generateChartPreview({
chart_data: scene.chart_data,
chart_type: scene.chart_data.type || "bar_comparison",
title: scene.title,
});
return {
...scene,
broll_preview_url: result.preview_url,
chart_id: result.chart_id,
};
} catch (err) {
console.error(`Failed to generate chart for scene ${scene.id}:`, err);
return scene;
}
})
);
const updatedScript = { ...activeScript, scenes: updatedScenes };
setScript(updatedScript);
emitScriptChange(updatedScript);
} catch (error: any) {
console.error("Chart preview generation failed:", error);
onError(`Failed to generate chart previews: ${error.message || error}`);
} finally {
setGeneratingChartId(null);
}
}, [activeScript, emitScriptChange, onError]);
const regenerateChart = useCallback(async (sceneId: string) => {
if (!activeScript) return;
const scene = activeScript.scenes.find((s) => s.id === sceneId);
if (!scene || !scene.chart_data) return;
try {
setGeneratingChartId(sceneId);
const result = await podcastApi.generateChartPreview({
chart_data: scene.chart_data,
chart_type: scene.chart_data.type || "bar_comparison",
title: scene.title,
});
const updatedScenes = activeScript.scenes.map((s) =>
s.id === sceneId
? { ...s, broll_preview_url: result.preview_url, chart_id: result.chart_id }
: s
);
const updatedScript = { ...activeScript, scenes: updatedScenes };
setScript(updatedScript);
emitScriptChange(updatedScript);
} catch (error: any) {
console.error("Chart regeneration failed:", error);
onError(`Failed to regenerate chart: ${error.message || error}`);
} finally {
setGeneratingChartId(null);
}
}, [activeScript, emitScriptChange, onError]);
const removeChart = useCallback((sceneId: string) => {
if (!activeScript) return;
const updatedScenes = activeScript.scenes.map((s) =>
s.id === sceneId
? { ...s, chart_data: undefined, broll_preview_url: undefined, broll_video_url: undefined }
: s
);
const updatedScript = { ...activeScript, scenes: updatedScenes };
setScript(updatedScript);
emitScriptChange(updatedScript);
}, [activeScript, emitScriptChange]);
const value: ScriptEditorContextType = {
// State
script,
loading,
error,
podcastMode,
approvingSceneId,
generatingAudioId,
showScriptFormatInfo,
combiningAudio,
scriptTab,
combinedAudioResult,
generatingBatchAudio,
batchAudioProgress,
generatingChartId,
// Computed
activeScript,
allApproved,
approvedCount,
totalScenes,
allScenesHaveAudio,
scenesWithAudio,
allScenesHaveAudioAndImages,
needsAudioGeneration,
scenesWithCharts,
// Setters for UI state
setScript,
setLoading,
setError,
setApprovingSceneId,
setGeneratingAudioId,
setShowScriptFormatInfo,
setCombiningAudio,
setScriptTab,
setCombinedAudioResult,
setGeneratingBatchAudio,
setBatchAudioProgress,
setGeneratingChartId,
// Actions
updateScene,
approveScene,
deleteScene,
generateAllAudio,
combineAudio,
emitScriptChange,
// B-roll actions
generateChartPreviews,
regenerateChart,
removeChart,
};
return (
<ScriptEditorContext.Provider value={value}>
{children}
</ScriptEditorContext.Provider>
);
};
export const useScriptEditor = (): ScriptEditorContextType => {
const context = useContext(ScriptEditorContext);
if (!context) {
throw new Error("useScriptEditor must be used within ScriptEditorProvider");
}
return context;
};

View File

@@ -0,0 +1,109 @@
import React from "react";
import { Box, Stack, Typography, Tabs, Tab, Chip } from "@mui/material";
import { EditNote as EditNoteIcon, ArrowBack as ArrowBackIcon, AudioFile as AudioFileIcon, Videocam as VideocamIcon, Mic as MicIcon } from "@mui/icons-material";
import { PodcastMode, Knobs } from "../types";
import { SecondaryButton } from "../ui";
import { useScriptEditor } from "./ScriptEditorContext";
interface ScriptEditorLayoutProps {
onBackToResearch: () => void;
knobs?: Knobs;
}
// Helper function to get voice display name
const getVoiceDisplayName = (voiceId?: string): string => {
if (!voiceId) return "Default";
if (voiceId === "Wise_Woman") return "Wise Woman";
if (voiceId === "Friendly_Person") return "Friendly Person";
if (voiceId === "Deep_Voice_Man") return "Deep Voice Man";
if (voiceId === "Calm_Woman") return "Calm Woman";
return voiceId.replace(/_/g, " ");
};
export const ScriptEditorLayout: React.FC<ScriptEditorLayoutProps> = ({ onBackToResearch, knobs }) => {
const { podcastMode, scriptTab, setScriptTab } = useScriptEditor();
const showTabs = podcastMode === "audio_video";
const voiceId = knobs?.voice_id;
const isCustomVoice = Boolean(voiceId && !voiceId.startsWith("builtin:") &&
!["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman",
"Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man",
"Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess",
"Sweet_Girl_2", "Exuberant_Girl"].includes(voiceId));
const voiceName = isCustomVoice ? "My Voice Clone" : getVoiceDisplayName(voiceId);
return (
<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
{voiceId && (
<Chip
icon={<MicIcon sx={{ fontSize: "14px !important" }} />}
label={`Active Voice: ${voiceName}`}
size="small"
sx={{
ml: 2,
background: isCustomVoice ? "rgba(16, 185, 129, 0.1)" : "rgba(99, 102, 241, 0.1)",
color: isCustomVoice ? "#10b981" : "#6366f1",
border: `1px solid ${isCustomVoice ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)"}`,
'& .MuiChip-icon': { color: isCustomVoice ? "#10b981" : "#6366f1" },
fontWeight: 600,
fontSize: "0.75rem",
}}
/>
)}
{showTabs && (
<Tabs
value={scriptTab}
onChange={(_, v) => setScriptTab(v)}
sx={{
ml: 3,
minHeight: 32,
'& .MuiTab-root': {
minHeight: 32,
py: 0.5,
px: 2,
fontSize: '0.8rem',
}
}}
>
<Tab
value="audio"
label="Audio Script"
icon={<AudioFileIcon sx={{ fontSize: '1rem' }} />}
iconPosition="start"
/>
<Tab
value="video"
label="Video Script"
icon={<VideocamIcon sx={{ fontSize: '1rem' }} />}
iconPosition="start"
/>
</Tabs>
)}
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
Review and refine your podcast script before rendering
</Typography>
</Box>
</Stack>
);
};

View File

@@ -0,0 +1,270 @@
import React from "react";
import {
Stack,
Typography,
CircularProgress,
LinearProgress,
Box,
Divider,
useMediaQuery,
useTheme,
} from "@mui/material";
import {
AutoAwesome as AutoAwesomeIcon,
CheckCircle as CheckCircleIcon,
Psychology as PsychologyIcon,
Insights as InsightsIcon,
Article as ArticleIcon,
Edit as EditIcon,
VolumeUp as VolumeUpIcon,
VideoLibrary as VideoLibraryIcon,
Lightbulb as LightbulbIcon,
Search as SearchIcon,
FactCheck as FactCheckIcon,
} from "@mui/icons-material";
import { Research } from "../types";
const SCRIPT_GENERATION_MESSAGES = [
{ title: "Processing Research", message: "Extracting key insights, statistics, and quotes from your research data..." },
{ title: "Analyzing Your Topic", message: "Using your topic to shape the episode narrative and content structure..." },
{ title: "Structuring Scenes", message: "Creating scene-by-scene breakdown based on research findings..." },
{ title: "Writing Dialogue", message: "Generating natural conversation that flows from your insights..." },
{ title: "Adding Transitions", message: "Creating smooth flow between scenes and topics..." },
{ title: "Optimizing Pacing", message: "Ensuring engaging rhythm throughout the episode..." },
{ title: "Final Review", message: "Validating script quality and preparing for editing..." },
];
const RESEARCH_STATS_CONFIG = [
{ label: "Key Insights", key: "keyInsights", icon: <InsightsIcon />, color: "#a78bfa" },
{ label: "Fact Cards", key: "factCards", icon: <FactCheckIcon />, color: "#34d399" },
{ label: "Angles", key: "mappedAngles", icon: <LightbulbIcon />, color: "#f59e0b" },
{ label: "Sources", key: "sourceCount", icon: <SearchIcon />, color: "#60a5fa", isNumber: true },
];
const PODCAST_CREATION_JOURNEY = [
{
phase: "Generate Script",
icon: <AutoAwesomeIcon />,
color: "#a78bfa",
description: "AI transforms research into a structured podcast script",
benefit: "Professional script based on your research insights"
},
{
phase: "Edit Scenes",
icon: <EditIcon />,
color: "#34d399",
description: "Review and refine each scene in the Script Editor",
benefit: "Full control over your content"
},
{
phase: "Approve Content",
icon: <CheckCircleIcon />,
color: "#10b981",
description: "Mark scenes as approved before audio generation",
benefit: "Ensures content isexactly as you want"
},
{
phase: "Generate Audio",
icon: <VolumeUpIcon />,
color: "#f59e0b",
description: "Convert script to natural-sounding podcast audio",
benefit: "Ready-to-use audio narration"
},
{
phase: "Final Render",
icon: <VideoLibraryIcon />,
color: "#ef4444",
description: "Combine into your final podcast episode",
benefit: "Download or share your episode"
},
];
interface ScriptProgressViewProps {
currentMessage?: string;
progressIndex: number;
research?: Research | null;
idea?: string;
}
export const ScriptProgressView: React.FC<ScriptProgressViewProps> = ({
currentMessage,
progressIndex,
research,
idea,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const clampedIndex = Math.min(progressIndex, SCRIPT_GENERATION_MESSAGES.length - 1);
const getResearchValue = (key: string, isNumber?: boolean) => {
if (!research) return 0;
const value = (research as any)[key];
if (isNumber) return research.sourceCount || 0;
return Array.isArray(value) ? value.length : value || 0;
};
return (
<Stack spacing={2}>
{/* Current Status */}
<Box sx={{ textAlign: "center" }}>
<Box sx={{ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<CircularProgress size={isMobile ? 50 : 60} thickness={3} sx={{ color: "#a78bfa" }} />
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
<AutoAwesomeIcon sx={{ color: "#a78bfa", fontSize: isMobile ? 20 : 24 }} />
</Box>
</Box>
<Typography variant="subtitle1" sx={{ color: "#a78bfa", fontWeight: 600, mt: 1, fontSize: isMobile ? "0.85rem" : "0.95rem" }}>
{SCRIPT_GENERATION_MESSAGES[clampedIndex].title}
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mt: 0.5, fontSize: isMobile ? "0.75rem" : "0.85rem", px: 1 }}>
{currentMessage || SCRIPT_GENERATION_MESSAGES[clampedIndex].message}
</Typography>
{currentMessage && (
<Typography variant="caption" sx={{ color: "#10b981", mt: 0.5, display: "block", fontSize: "0.75rem" }}>
{currentMessage}
</Typography>
)}
<LinearProgress
sx={{
width: "100%",
height: 4,
borderRadius: 2,
bgcolor: "rgba(255,255,255,0.1)",
mt: 2,
"& .MuiLinearProgress-bar": { bgcolor: "#a78bfa", borderRadius: 2 },
}}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.4)", mt: 0.5, display: "block" }}>
Step {clampedIndex + 1} of {SCRIPT_GENERATION_MESSAGES.length}
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Research Stats */}
{research && (
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Using Your Research
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{RESEARCH_STATS_CONFIG.map((stat, idx) => {
const value = getResearchValue(stat.key, stat.isNumber);
return (
<Box key={idx} sx={{
flex: "1 1 auto",
minWidth: 80,
p: 1,
borderRadius: 1.5,
bgcolor: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.1)",
textAlign: "center",
}}>
<Box sx={{ color: stat.color, mb: 0.25 }}>{stat.icon}</Box>
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 700, fontSize: "1rem" }}>
{value}
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem" }}>
{stat.label}
</Typography>
</Box>
);
})}
</Stack>
</Box>
)}
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Sequential Progress Steps */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Script Generation Progress
</Typography>
<Stack spacing={0.5}>
{SCRIPT_GENERATION_MESSAGES.map((msg, idx) => {
const isCompleted = idx < clampedIndex;
const isCurrent = idx === clampedIndex;
return (
<Stack key={idx} direction="row" spacing={1} alignItems="flex-start">
<Box sx={{
width: 18,
height: 18,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: isCompleted ? "#10b981" : isCurrent ? "#a78bfa" : "rgba(255,255,255,0.1)",
flexShrink: 0,
}}>
{isCompleted ? (
<CheckCircleIcon sx={{ fontSize: 12, color: "#fff" }} />
) : isCurrent ? (
<CircularProgress size={10} sx={{ color: "#fff" }} />
) : (
<Box sx={{ width: 4, height: 4, borderRadius: "50%", bgcolor: "rgba(255,255,255,0.3)" }} />
)}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{
color: isCompleted ? "rgba(255,255,255,0.5)" : isCurrent ? "#a78bfa" : "rgba(255,255,255,0.6)",
fontWeight: isCurrent ? 600 : 400,
fontSize: "0.75rem",
textDecoration: isCompleted ? "line-through" : "none",
}}>
{msg.title}
</Typography>
</Box>
</Stack>
);
})}
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Journey Overview */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Your Podcast Journey
</Typography>
<Stack spacing={1}>
{PODCAST_CREATION_JOURNEY.map((phase, idx) => (
<Box key={idx} sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)" }}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{
width: 28,
height: 28,
borderRadius: "50%",
bgcolor: `${phase.color}20`,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}>
{React.cloneElement(phase.icon, { sx: { color: phase.color, fontSize: 16 } })}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem" }}>
{phase.phase}
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.7rem", display: "block" }}>
{phase.description}
</Typography>
<Typography variant="caption" sx={{ color: phase.color, fontSize: "0.65rem", display: "block", mt: 0.25 }}>
{phase.benefit}
</Typography>
</Box>
</Stack>
</Box>
))}
</Stack>
</Box>
</Stack>
);
};

View File

@@ -0,0 +1,140 @@
import React from "react";
import { Stack, Box, Typography, Alert, Paper, Button, CircularProgress, Chip } from "@mui/material";
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon } from "@mui/icons-material";
import { useScriptEditor } from "../ScriptEditorContext";
export const BrollInfoPanel: React.FC = () => {
const {
activeScript,
generatingChartId,
setGeneratingChartId,
generateChartPreviews,
regenerateChart,
removeChart,
scenesWithCharts
} = useScriptEditor();
if (!activeScript || activeScript.scenes.length === 0) {
return null;
}
const scenesWithData = activeScript.scenes.filter(s => s.chart_data && Object.keys(s.chart_data).length > 0);
const hasChartData = scenesWithData.length > 0;
return (
<Paper
sx={{
p: 3,
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.05) 0%, rgba(16, 185, 129, 0.05) 100%)",
border: "1px solid rgba(34, 197, 94, 0.15)",
borderRadius: 2,
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 2 }}>
<Box sx={{ width: 40, height: 40, borderRadius: "50%", background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<BarChartIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
B-Roll Charts
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
Programmatic charts extracted from research data
</Typography>
</Box>
{hasChartData && (
<Chip
label={`${scenesWithData.length} scene${scenesWithData.length > 1 ? 's' : ''} with charts`}
size="small"
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a", fontWeight: 600 }}
/>
)}
</Stack>
{!hasChartData ? (
<Alert severity="info" sx={{ background: "rgba(34, 197, 94, 0.06)", border: "1px solid rgba(34, 197, 94, 0.15)", "& .MuiAlert-icon": { color: "#22c55e" } }}>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
<strong style={{ fontWeight: 600 }}>No charts detected.</strong> If your research contains statistics or metrics, the script generation will automatically extract chart data for B-roll visualization.
</Typography>
</Alert>
) : (
<Stack spacing={2}>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
Your script contains <strong style={{ fontWeight: 600 }}>{scenesWithData.length}</strong> scene(s) with chart data.
Click below to generate chart previews for the Write phase.
</Typography>
<Stack direction="row" spacing={2}>
<Button
variant="contained"
startIcon={generatingChartId ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
onClick={generateChartPreviews}
disabled={!!generatingChartId}
sx={{
background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)",
"&:hover": { background: "linear-gradient(135deg, #16a34a 0%, #059669 100%)" },
textTransform: "none",
fontWeight: 600,
}}
>
{generatingChartId ? "Generating..." : "Generate Chart Previews"}
</Button>
</Stack>
{scenesWithData.map((scene) => (
<Box
key={scene.id}
sx={{
p: 2,
background: "rgba(0,0,0,0.02)",
borderRadius: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{scene.title}
</Typography>
<Typography variant="caption" sx={{ color: "#64748b" }}>
{scene.chart_data?.type || "chart"} {scene.chart_data?.labels?.length || 0} data points
</Typography>
</Box>
<Stack direction="row" spacing={1}>
{generatingChartId === scene.id ? (
<CircularProgress size={20} />
) : scene.broll_preview_url ? (
<>
<Chip
label="Preview Ready"
size="small"
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a" }}
/>
<Button
size="small"
startIcon={<RefreshIcon />}
onClick={() => regenerateChart(scene.id)}
>
Regenerate
</Button>
<Button
size="small"
startIcon={<DeleteIcon />}
onClick={() => removeChart(scene.id)}
sx={{ color: "#ef4444" }}
>
Remove
</Button>
</>
) : null}
</Stack>
</Box>
))}
</Stack>
)}
</Paper>
);
};

View File

@@ -0,0 +1,35 @@
import React from "react";
import { Stack, Box, Typography, Paper, LinearProgress } from "@mui/material";
import { CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon } from "@mui/icons-material";
import { Script } from "../../types";
import { useScriptEditor } from "../ScriptEditorContext";
import { PrimaryButton } from "../../ui";
interface ScriptEditorApprovalPanelProps {
onProceedToRendering: (script: Script) => void;
}
export const ScriptEditorApprovalPanel: React.FC<ScriptEditorApprovalPanelProps> = ({ onProceedToRendering }) => {
const { activeScript, allApproved, approvedCount, totalScenes, allScenesHaveAudioAndImages } = useScriptEditor();
const approved = allApproved ?? false;
const ready = allScenesHaveAudioAndImages ?? false;
return (
<Paper sx={{ p: 3.5, background: approved ? "linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(5, 150, 105, 0.05) 100%)" : "#ffffff", border: approved ? "2px solid rgba(16, 185, 129, 0.25)" : "1px solid rgba(15, 23, 42, 0.08)", borderRadius: 3 }}>
<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: approved ? "#10b981" : "#94a3b8", fontSize: "1.25rem" }} />Approval Status
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 400, lineHeight: 1.6 }}>
{approvedCount} of {totalScenes} scenes approved{!approved && " — Approve all scenes first"}
</Typography>
{!ready && <LinearProgress variant="determinate" value={ready ? 100 : (activeScript ? (activeScript.scenes.filter((s) => s.audioUrl && s.imageUrl).length / totalScenes) * 100 : 0)} sx={{ mt: 1, height: 6, borderRadius: 3 }} />}
</Box>
<PrimaryButton onClick={() => activeScript && onProceedToRendering(activeScript)} disabled={!ready} startIcon={<PlayArrowIcon />}>
Proceed to Rendering
</PrimaryButton>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,53 @@
import React from "react";
import { Stack, Box, Typography, Paper, LinearProgress } from "@mui/material";
import { AudioFile as AudioFileIcon } from "@mui/icons-material";
import { useScriptEditor } from "../ScriptEditorContext";
import { PrimaryButton } from "../../ui";
export const ScriptEditorAudioPanel: React.FC = () => {
const { activeScript, needsAudioGeneration, generatingBatchAudio, batchAudioProgress, generateAllAudio } = useScriptEditor();
if (!(needsAudioGeneration ?? false)) {
return null;
}
return (
<Paper
sx={{
p: 2,
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.08) 100%)",
border: "1px solid rgba(16, 185, 129, 0.2)",
borderRadius: 2,
}}
>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="subtitle1" sx={{ color: "#059669", fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<AudioFileIcon /> Generate All Audio
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5 }}>
{activeScript && `${activeScript.scenes.filter(s => !s.audioUrl).length} scenes need audio`}
</Typography>
</Box>
<PrimaryButton
onClick={generateAllAudio}
disabled={generatingBatchAudio}
loading={generatingBatchAudio}
startIcon={<AudioFileIcon />}
sx={{ background: "linear-gradient(135deg, #10b981 0%, #059669 100%)" }}
>
{generatingBatchAudio
? (batchAudioProgress ? `Generating ${batchAudioProgress.completed}/${batchAudioProgress.total}...` : "Generating...")
: "Generate All Audio"}
</PrimaryButton>
</Stack>
{(batchAudioProgress !== null && batchAudioProgress !== undefined) && (
<LinearProgress
variant="determinate"
value={(batchAudioProgress.completed / batchAudioProgress.total) * 100}
sx={{ mt: 2, height: 8, borderRadius: 4 }}
/>
)}
</Paper>
);
};

View File

@@ -0,0 +1,70 @@
import React from "react";
import { Stack, Typography, Paper, Alert, alpha } from "@mui/material";
import { Download as DownloadIcon, Refresh as RefreshIcon } from "@mui/icons-material";
import { useScriptEditor } from "../ScriptEditorContext";
import { PrimaryButton, SecondaryButton } from "../../ui";
import { InlineAudioPlayer } from "../../InlineAudioPlayer";
import { aiApiClient } from "../../../../api/client";
interface ScriptEditorDownloadPanelProps {
projectId: string;
}
export const ScriptEditorDownloadPanel: React.FC<ScriptEditorDownloadPanelProps> = ({ projectId }) => {
const { allScenesHaveAudio, scenesWithAudio, combiningAudio, combinedAudioResult, combineAudio, setCombinedAudioResult } = useScriptEditor();
if (!(allScenesHaveAudio ?? false)) {
return null;
}
const handleDownloadAgain = async () => {
if (!combinedAudioResult) return;
try {
let audioPath = combinedAudioResult.url.startsWith('/') ? combinedAudioResult.url : `/${combinedAudioResult.url}`;
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || combinedAudioResult.filename;
audioPath = `/api/podcast/audio/${filename}`;
}
audioPath = audioPath.split('?')[0];
const response = await aiApiClient.get(audioPath, { responseType: 'blob' });
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);
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
} catch (error) {
console.error('Failed to download audio:', error);
}
};
return (
<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 />} sx={{ minWidth: 280, fontSize: "1rem", py: 1.5, background: "linear-gradient(135deg, #667eea 0%, #764ba2 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}>
<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>
<InlineAudioPlayer audioUrl={combinedAudioResult.url} title="Complete Podcast Episode" />
<Stack direction="row" spacing={2}>
<SecondaryButton onClick={handleDownloadAgain} startIcon={<DownloadIcon />}>Download Again</SecondaryButton>
<SecondaryButton onClick={() => { setCombinedAudioResult(null); combineAudio(); }} disabled={combiningAudio} loading={combiningAudio} startIcon={<RefreshIcon />}>Regenerate</SecondaryButton>
</Stack>
</Stack>
)}
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,87 @@
import React from "react";
import { Stack, Box, Typography, Alert, Paper } from "@mui/material";
import { Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon } from "@mui/icons-material";
import { useScriptEditor } from "../ScriptEditorContext";
interface FormatItem {
num: string;
title: string;
desc: string;
}
const formatItems: FormatItem[] = [
{ num: "1", title: "Natural Pauses & Rhythm", desc: "The script includes strategic pauses between lines and when speakers change. This creates natural breathing patterns and conversation flow." },
{ num: "2", title: "Emphasis Markers", desc: "Lines marked with emphasis help highlight important points, statistics, or key insights." },
{ num: "3", title: "Short, Conversational Sentences", desc: "The script uses shorter sentences written in a conversational style that matches how people actually speak." },
{ num: "4", title: "Scene-Specific Emotions", desc: "Each scene has an emotional tone that guides the AI voice's delivery." },
{ num: "5", title: "Optimized for Podcast Narration", desc: "The script is optimized with slightly slower pacing and natural pronunciation settings." },
];
export const ScriptEditorInfoPanel: React.FC = () => {
const { showScriptFormatInfo, setShowScriptFormatInfo } = useScriptEditor();
return (
<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,
}}
>
<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" }}>
<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>
<Box
sx={{
color: "#6366f1",
cursor: "pointer",
p: 0.5,
borderRadius: 1,
"&:hover": { background: "rgba(99, 102, 241, 0.1)" },
}}
onClick={() => setShowScriptFormatInfo(!showScriptFormatInfo)}
>
{showScriptFormatInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
</Stack>
{showScriptFormatInfo && (
<Stack spacing={2.5}>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.8 }}>
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>
<Stack spacing={2}>
{formatItems.map((item) => (
<Box key={item.num} 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" }}>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>{item.num}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>{item.title}</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>{item.desc}</Typography>
</Box>
</Box>
))}
</Stack>
<Alert severity="info" sx={{ 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.
</Typography>
</Alert>
</Stack>
)}
</Paper>
);
};