Podcast Maker: Fix progress modals, research JSON, header stepper, voice/podcastMode chips
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user