feat(phase-4): UI/UX improvements for Podcast Maker Write phase

Frontend Changes:
- Add scene numbering badge (1/N) next to scene titles
- Add inline status chips (Complete, Audio, Image, Voice, Why Script)
- Professional AI-like gradient styling for all chips with shadows
- Remove Script Editor header and 'Why This Script Format?' collapsible
- Move Voice and Why Script info to per-scene chips
- Make scene section mobile-responsive (responsive layout, button sizing)
- Rename 'B-Roll Charts' to 'Podcast Charts' with accordion (collapsed by default)
- Add sceneIndex prop to SceneEditor for scene numbering
- Enhanced accessibility with keyboard navigation and focus states

Backend Changes:
- Audio handler improvements
- B-roll handler enhancements
- Script handler updates
- B-roll composer and service improvements
- Removed temporary broll_temp files

Technical:
- Full mobile responsiveness for scene cards
- Gradient chip styling: vibrant colors with white text and shadows
- Non-breaking approval/generation flow preserved
- TypeScript compatibility maintained
This commit is contained in:
ajaysi
2026-04-24 15:44:09 +05:30
parent 8b79099b15
commit ba94ee30bc
16 changed files with 977 additions and 2126 deletions

View File

@@ -6,6 +6,9 @@ export const DEFAULT_KNOBS: Knobs = {
voice_speed: 1,
voice_id: "Wise_Woman",
custom_voice_id: undefined,
is_voice_clone: undefined,
voice_sample_url: undefined,
voice_clone_engine: undefined,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,

View File

@@ -10,9 +10,14 @@ import {
Delete as DeleteIcon,
Fullscreen as FullscreenIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
Info as InfoIcon,
Mic as MicIcon,
HelpOutline as HelpOutlineIcon,
} from "@mui/icons-material";
import { Scene, Line, Knobs } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { OperationButton } from "../../shared/OperationButton";
import { LineEditor } from "./LineEditor";
import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerateModal";
import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal";
@@ -33,6 +38,7 @@ interface SceneEditorProps {
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
sceneIndex?: number; // Current scene index (0-based) for 1/N numbering
analysis?: {
audience?: string;
contentType?: string;
@@ -53,6 +59,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
idea,
avatarUrl,
totalScenes,
sceneIndex,
analysis,
}) => {
const [localGenerating, setLocalGenerating] = useState(false);
@@ -65,6 +72,8 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
const [showAudioModal, setShowAudioModal] = useState(false);
const [showImagePreview, setShowImagePreview] = useState(false);
const [showApprovalInfo, setShowApprovalInfo] = useState(false);
const [showWhyScript, setShowWhyScript] = useState(false);
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
voiceId: knobs.voice_id || "Wise_Woman",
customVoiceId: knobs.custom_voice_id || undefined,
@@ -283,6 +292,15 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
const generating = generatingAudioId === scene.id || localGenerating;
const hasAudio = Boolean(scene.audioUrl && audioBlobUrl);
const hasImage = Boolean(scene.imageUrl);
// Completion status for visual feedback
const isComplete = hasAudio && hasImage;
const completionPercent = (hasAudio ? 50 : 0) + (hasImage ? 50 : 0);
// Scene order for 1/N badge display
const sceneOrder = sceneIndex != null ? sceneIndex + 1 : null;
const totalScenesInline = totalScenes ?? null;
const showOrderBadge = sceneOrder != null && totalScenesInline != null;
const handleApproveAndGenerate = async (settings?: AudioGenerationSettings) => {
const wasAlreadyApproved = scene.approved;
@@ -507,124 +525,402 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
};
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2.5}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box sx={{ flex: 1 }}>
<GlassyCard
sx={{
...glassyCardSx,
border: isComplete
? "2px solid #10b981"
: completionPercent > 0
? "2px solid #f59e0b"
: "1px solid rgba(15, 23, 42, 0.08)",
background: isComplete
? "linear-gradient(135deg, rgba(16, 185, 129, 0.03) 0%, rgba(255, 255, 255, 0.9) 100%)"
: completionPercent > 0
? "linear-gradient(135deg, rgba(245, 158, 11, 0.03) 0%, rgba(255, 255, 255, 0.9) 100%)"
: glassyCardSx.background,
boxShadow: isComplete
? "0 4px 20px rgba(16, 185, 129, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)"
: completionPercent > 0
? "0 4px 20px rgba(245, 158, 11, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)"
: glassyCardSx.boxShadow,
}}
>
{/* Completion Progress Bar */}
<Box sx={{ position: "relative", height: 4, mb: 2, borderRadius: 2, overflow: "hidden", backgroundColor: "rgba(0,0,0,0.04)" }}>
<Box
sx={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: `${completionPercent}%`,
background: isComplete
? "linear-gradient(90deg, #10b981 0%, #059669 100%)"
: "linear-gradient(90deg, #f59e0b 0%, #d97706 100%)",
transition: "width 0.5s ease",
}}
/>
</Box>
<Stack spacing={{ xs: 2, sm: 2.5 }}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', sm: 'flex-start' }}
spacing={{ xs: 2, sm: 0 }}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="h6"
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
mb: 1,
gap: { xs: 1, sm: 1.5 },
mb: { xs: 0.75, sm: 1 },
color: "#0f172a",
fontWeight: 600,
fontSize: "1.25rem",
fontSize: { xs: "1.1rem", sm: "1.25rem" },
letterSpacing: "-0.01em",
flexWrap: 'wrap',
}}
>
<EditNoteIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1.5rem" }} />
{scene.title}
<EditNoteIcon fontSize="small" sx={{ color: isComplete ? "#059669" : completionPercent > 0 ? "#d97706" : "#667eea", fontSize: { xs: "1.25rem", sm: "1.5rem" } }} />
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: { xs: 'normal', sm: 'nowrap' } }}>
{scene.title}
</Box>
{showOrderBadge && (
<Chip
label={`${sceneOrder}/${totalScenesInline}`}
size="small"
sx={{
ml: { xs: 0, sm: 0.5 },
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
height: { xs: 20, sm: 24 },
fontSize: { xs: '0.65rem', sm: '0.7rem' },
fontWeight: 700,
color: '#ffffff',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.3)',
'& .MuiChip-label': {
px: { xs: 0.75, sm: 1 },
},
}}
/>
)}
</Typography>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
<Stack direction="row" spacing={{ xs: 1, sm: 1.5 }} alignItems="center" flexWrap="wrap">
{/* Completion Status Chip */}
<Chip
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
label={scene.approved ? "Approved" : "Pending Approval"}
icon={isComplete ? <CheckCircleIcon /> : completionPercent > 0 ? <RefreshIcon /> : <RadioButtonUncheckedIcon />}
label={isComplete ? "Complete" : completionPercent > 0 ? `In Progress ${completionPercent}%` : "Pending"}
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,
background: isComplete
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: completionPercent > 0
? "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)"
: "linear-gradient(135deg, #64748b 0%, #475569 100%)",
color: "#ffffff",
border: "none",
fontWeight: 700,
fontSize: "0.75rem",
height: 26,
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
borderRadius: "12px",
px: 1,
boxShadow: isComplete
? "0 3px 12px rgba(16, 185, 129, 0.4)"
: completionPercent > 0
? "0 3px 12px rgba(245, 158, 11, 0.4)"
: "0 2px 6px rgba(100, 116, 139, 0.3)",
'& .MuiChip-icon': {
fontSize: '1rem',
color: '#ffffff',
},
'& .MuiChip-label': {
pl: 0.5,
},
}}
/>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem" }}>
{/* Audio Status */}
<Chip
icon={hasAudio ? <CheckCircleIcon sx={{ fontSize: '0.875rem !important' }} /> : <VolumeUpIcon sx={{ fontSize: '0.875rem !important' }} />}
label={hasAudio ? "Audio Ready" : "No Audio"}
size="small"
sx={{
background: hasAudio
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: "linear-gradient(135deg, #94a3b8 0%, #64748b 100%)",
color: "#ffffff",
border: "none",
fontWeight: 600,
fontSize: "0.7rem",
height: 22,
borderRadius: "10px",
px: 0.75,
boxShadow: hasAudio ? "0 2px 8px rgba(16, 185, 129, 0.35)" : "0 1px 4px rgba(100, 116, 139, 0.25)",
'& .MuiChip-icon': {
color: '#ffffff',
},
'& .MuiChip-label': {
pl: 0.5,
},
}}
/>
{/* Image Status */}
<Chip
icon={hasImage ? <CheckCircleIcon sx={{ fontSize: '0.875rem !important' }} /> : <ImageIcon sx={{ fontSize: '0.875rem !important' }} />}
label={hasImage ? "Image Ready" : "No Image"}
size="small"
sx={{
background: hasImage
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: "linear-gradient(135deg, #94a3b8 0%, #64748b 100%)",
color: "#ffffff",
border: "none",
fontWeight: 600,
fontSize: "0.7rem",
height: 22,
borderRadius: "10px",
px: 0.75,
boxShadow: hasImage ? "0 2px 8px rgba(16, 185, 129, 0.35)" : "0 1px 4px rgba(100, 116, 139, 0.25)",
'& .MuiChip-icon': {
color: '#ffffff',
},
'& .MuiChip-label': {
pl: 0.5,
},
}}
/>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem", ml: 1 }}>
Duration: {scene.duration}s
</Typography>
</Stack>
{/* Approval Info Panel - Inline chips for guidance */}
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" sx={{ mt: 1 }}>
{/* Active Voice indicator */}
<Chip
icon={<MicIcon sx={{ fontSize: '0.875rem !important' }} />}
label={`Voice: ${knobs.voice_id === "Wise_Woman" ? "Wise Woman" : knobs.voice_id?.replace(/_/g, " ") || "Default"}`}
size="small"
sx={{
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "#ffffff",
border: "none",
fontWeight: 600,
fontSize: "0.7rem",
height: 22,
borderRadius: "10px",
px: 0.75,
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
'& .MuiChip-icon': { color: '#ffffff' },
'& .MuiChip-label': {
pl: 0.5,
},
}}
/>
{/* Why Script chip - opens modal with guidance */}
<Chip
icon={<HelpOutlineIcon sx={{ fontSize: '0.875rem !important' }} />}
label="Why Script?"
size="small"
onClick={() => setShowWhyScript(true)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowWhyScript(true);
}
}}
tabIndex={0}
role="button"
aria-label="Learn why scene approval is required"
sx={{
background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)",
color: "#ffffff",
border: "none",
fontWeight: 600,
fontSize: "0.7rem",
height: 22,
borderRadius: "10px",
px: 0.75,
cursor: "pointer",
boxShadow: "0 2px 8px rgba(245, 158, 11, 0.35)",
'& .MuiChip-icon': { color: '#ffffff' },
'& .MuiChip-label': {
pl: 0.5,
},
'&:hover': {
background: "linear-gradient(135deg, #d97706 0%, #b45309 100%)",
boxShadow: "0 3px 10px rgba(245, 158, 11, 0.45)",
},
'&:focus': {
outline: '2px solid rgba(245, 158, 11, 0.6)',
outlineOffset: '2px',
},
}}
/>
</Stack>
</Box>
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
<PrimaryButton
onClick={handleAudioRegenerateClick}
disabled={approving || generating}
loading={approving || generating}
startIcon={
hasAudio && !generating ? (
<VolumeUpIcon />
) : generating ? (
<CircularProgress size={16} sx={{ color: "white" }} />
) : (
<PlayArrowIcon />
)
}
tooltip={
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={{ xs: 1, sm: 1.5 }}
flexWrap="wrap"
useFlexGap
sx={{
width: { xs: '100%', sm: 'auto' },
'& > *': { width: { xs: '100%', sm: 'auto' } }
}}
>
<Tooltip
title={
hasAudio && !generating
? "Regenerate audio for this scene with custom settings"
? "✓ Audio generated! Click to regenerate with different settings"
: generating
? "Generating audio..."
? "Generating audio... please wait"
: scene.approved
? "Generate audio for this scene"
: "Approve scene and generate audio"
}
sx={{
minWidth: 200,
}}
>
{hasAudio && !generating
? "Regenerate Audio"
: generating
? "Generating Audio..."
: scene.approved
? "Generate Audio"
: "Approve & Generate Audio"}
</PrimaryButton>
<PrimaryButton
onClick={hasImage ? handleRegenerateClick : () => handleGenerateImage()}
disabled={generatingImage}
loading={generatingImage}
startIcon={
hasImage && !generatingImage ? (
<ImageIcon />
) : generatingImage ? (
<CircularProgress size={16} sx={{ color: "white" }} />
) : (
<ImageIcon />
)
}
tooltip={
hasImage
? "Regenerate image for this scene"
<Box>
<OperationButton
operation={{
provider: "audio",
model: "minimax/speech-02-hd",
tokens_requested: scene.lines.reduce((sum, l) => sum + l.text.length, 0),
operation_type: "tts_full_render",
actual_provider_name: "wavespeed",
}}
label={
hasAudio && !generating
? "✓ Regenerate Audio"
: generating
? "Generating Audio..."
: scene.approved
? "Generate Audio"
: "Approve & Generate Audio"
}
variant="contained"
size="medium"
startIcon={
hasAudio && !generating ? (
<RefreshIcon />
) : generating ? (
<CircularProgress size={16} sx={{ color: "white" }} />
) : (
<PlayArrowIcon />
)
}
showCost={true}
checkOnHover={true}
checkOnMount={false}
onClick={handleAudioRegenerateClick}
disabled={approving || generating}
loading={approving || generating}
sx={{
minWidth: { xs: '100%', sm: 200 },
background: hasAudio
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
fontWeight: 600,
textTransform: "none",
fontSize: { xs: '0.8rem', sm: '0.875rem' },
py: { xs: 0.75, sm: 1 },
boxShadow: hasAudio
? "0 4px 14px rgba(16, 185, 129, 0.35)"
: "0 4px 14px rgba(102, 126, 234, 0.35)",
"&:hover": {
background: hasAudio
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
boxShadow: hasAudio
? "0 6px 20px rgba(16, 185, 129, 0.45)"
: "0 6px 20px rgba(102, 126, 234, 0.45)",
},
"&:disabled": {
background: alpha("#9ca3af", 0.3),
color: alpha("#fff", 0.5),
},
}}
/>
</Box>
</Tooltip>
<Tooltip
title={
hasImage && !generatingImage
? "✓ Image generated! Click to regenerate with different settings"
: generatingImage
? "Generating image..."
: "Generate image for video (optional)"
? "Generating image... please wait"
: "Generate image for video (optional but recommended)"
}
sx={{
minWidth: 180,
background: hasImage
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"&:hover": {
background: hasImage
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
},
}}
>
{hasImage && !generatingImage
? "Regenerate Image"
: generatingImage
? "Generating Image..."
: "Generate Image"}
</PrimaryButton>
<Box>
<OperationButton
operation={{
provider: "stability",
operation_type: "image_generation",
actual_provider_name: "wavespeed",
}}
label={
hasImage && !generatingImage
? "✓ Regenerate Image"
: generatingImage
? "Generating Image..."
: "Generate Image"
}
variant="contained"
size="medium"
startIcon={
hasImage && !generatingImage ? (
<RefreshIcon />
) : generatingImage ? (
<CircularProgress size={16} sx={{ color: "white" }} />
) : (
<ImageIcon />
)
}
showCost={true}
checkOnHover={true}
checkOnMount={false}
onClick={hasImage ? handleRegenerateClick : () => handleGenerateImage()}
disabled={generatingImage}
loading={generatingImage}
sx={{
minWidth: { xs: '100%', sm: 180 },
background: hasImage
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
fontWeight: 600,
textTransform: "none",
fontSize: { xs: '0.8rem', sm: '0.875rem' },
py: { xs: 0.75, sm: 1 },
boxShadow: hasImage
? "0 4px 14px rgba(16, 185, 129, 0.35)"
: "0 4px 14px rgba(102, 126, 234, 0.35)",
"&:hover": {
background: hasImage
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
boxShadow: hasImage
? "0 6px 20px rgba(16, 185, 129, 0.45)"
: "0 6px 20px rgba(102, 126, 234, 0.45)",
},
"&:disabled": {
background: alpha("#9ca3af", 0.3),
color: alpha("#fff", 0.5),
},
}}
/>
</Box>
</Tooltip>
<Tooltip title={totalScenes && totalScenes <= 1 ? "Cannot delete the last scene" : "Delete this scene"}>
<IconButton
@@ -635,7 +931,8 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
backgroundColor: "rgba(239, 68, 68, 0.1)",
border: "1px solid rgba(239, 68, 68, 0.2)",
borderRadius: 2,
padding: 1.5,
padding: { xs: 1, sm: 1.5 },
alignSelf: { xs: 'center', sm: 'auto' },
"&:hover": {
backgroundColor: "rgba(239, 68, 68, 0.15)",
borderColor: "rgba(239, 68, 68, 0.3)",
@@ -647,7 +944,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
},
}}
>
<DeleteIcon sx={{ fontSize: "1.25rem" }} />
<DeleteIcon sx={{ fontSize: { xs: "1.1rem", sm: "1.25rem" } }} />
</IconButton>
</Tooltip>
</Stack>
@@ -903,6 +1200,70 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
/>
</DialogContent>
</Dialog>
{/* Why Script Modal - Guidance for scene approval */}
<Dialog
open={showWhyScript}
onClose={() => setShowWhyScript(false)}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
p: 2,
}
}}
>
<DialogContent>
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<HelpOutlineIcon sx={{ color: '#d97706', fontSize: '1.5rem' }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: '#0f172a' }}>
Why Approve This Scene?
</Typography>
</Box>
<Divider />
<Typography variant="body2" sx={{ color: '#475569', lineHeight: 1.7 }}>
Each scene requires <strong>approval</strong> before audio can be generated. Here&apos;s why the approval process matters:
</Typography>
<Box sx={{
p: 2,
background: 'rgba(245, 158, 11, 0.08)',
borderRadius: 2,
border: '1px solid rgba(245, 158, 11, 0.2)',
}}>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<CheckCircleIcon sx={{ color: '#10b981', fontSize: '1.25rem' }} />
<Typography variant="body2" sx={{ color: '#059669', flex: 1 }}>
<strong>Script Accuracy:</strong> The AI generates audio based on the script text. Once approved, the text is locked to ensure consistency.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<CheckCircleIcon sx={{ color: '#10b981', fontSize: '1.25rem' }} />
<Typography variant="body2" sx={{ color: '#059669', flex: 1 }}>
<strong>Cost Control:</strong> Audio generation uses your subscription credits. Approving ensures you only pay for scenes you intend to render.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<CheckCircleIcon sx={{ color: '#10b981', fontSize: '1.25rem' }} />
<Typography variant="body2" sx={{ color: '#059669', flex: 1 }}>
<strong>Quality Check:</strong> Review your script for tone, pacing, and accuracy before spending credits on audio generation.
</Typography>
</Box>
</Stack>
</Box>
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
Pro tip: You can always regenerate audio later with different voice settings after approval.
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<PrimaryButton onClick={() => setShowWhyScript(false)}>
Got it
</PrimaryButton>
</Box>
</Stack>
</DialogContent>
</Dialog>
</GlassyCard>
);
};

View File

@@ -1,13 +1,13 @@
import React, { useEffect, useState, useCallback } from "react";
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 { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, IconButton, Divider, Chip, Tooltip } from "@mui/material";
import { CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, 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 { aiApiClient } from "../../../api/client";
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
import { SceneEditor } from "./SceneEditor";
import { InlineAudioPlayer } from "../InlineAudioPlayer";
import { aiApiClient, getApiUrl } from "../../../api/client";
import { BrollInfoPanel } from "./parts/BrollInfoPanel";
import { ScriptEditorProvider } from "./ScriptEditorContext";
@@ -53,8 +53,6 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
const [error, setError] = useState<string | null>(null);
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(false);
const [generatingChartId, setGeneratingChartId] = useState<string | null>(null);
const [combiningAudio, setCombiningAudio] = useState(false);
const [combinedAudioResult, setCombinedAudioResult] = useState<{
url: string;
@@ -242,108 +240,6 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
}
}, [script, projectId, onError]);
const generateChartPreviews = useCallback(async () => {
if (!script) return;
const scenesWithData = script.scenes.filter(
(scene) => scene.chart_data && Object.keys(scene.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(
script.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,
});
console.log(`[ChartPreview] Scene ${scene.id}: type=${scene.chart_data.type || 'bar_comparison'}, data=`, scene.chart_data);
const toFullUrl = (url: string) => {
if (/^https?:\/\//i.test(url)) return url;
return `${getApiUrl()}${url.startsWith("/") ? url : `/${url}`}`;
};
return {
...scene,
broll_preview_url: toFullUrl(result.preview_url),
chart_id: result.chart_id,
};
} catch (error) {
console.error(`[ChartPreview] Failed for scene ${scene.id}:`, error);
return scene;
}
})
);
const updatedScript = { ...script, 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);
}
}, [script, emitScriptChange, onError]);
const regenerateChart = useCallback(async (sceneId: string) => {
if (!script) return;
const scene = script.scenes.find((s) => s.id === sceneId);
if (!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 updatedScript = {
...script,
scenes: script.scenes.map((s) =>
s.id === sceneId
? { ...s, broll_preview_url: result.preview_url, chart_id: result.chart_id }
: s
),
};
setScript(updatedScript);
emitScriptChange(updatedScript);
} catch (error: any) {
console.error("Chart regeneration failed:", error);
onError(`Failed to regenerate chart: ${error.message || error}`);
} finally {
setGeneratingChartId(null);
}
}, [script, emitScriptChange, onError]);
const removeChart = useCallback((sceneId: string) => {
if (!script) return;
const updatedScript = {
...script,
scenes: script.scenes.map((scene) =>
scene.id === sceneId
? { ...scene, chart_data: undefined, broll_preview_url: undefined, broll_video_url: undefined }
: scene
),
};
setScript(updatedScript);
emitScriptChange(updatedScript);
}, [script, emitScriptChange]);
return (
<ScriptEditorProvider
projectId={projectId}
@@ -367,50 +263,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
<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
{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
</Typography>
</Box>
</Stack>
</Stack>
{loading && (
<Alert
@@ -455,225 +308,6 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
{script && (
<Stack spacing={3}>
{/* Script Format Explanation Panel */}
<Paper
sx={{
p: 3,
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)",
border: "1px solid rgba(99, 102, 241, 0.15)",
borderRadius: 2,
boxShadow: "0 2px 8px rgba(99, 102, 241, 0.08)",
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: showScriptFormatInfo ? 2 : 0 }}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: "50%",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
}}
>
<InfoIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
</Box>
<Box>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
Why This Script Format?
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
Understanding how your script creates natural, human-like audio
</Typography>
</Box>
</Stack>
<IconButton
onClick={() => setShowScriptFormatInfo(!showScriptFormatInfo)}
sx={{
color: "#6366f1",
"&:hover": {
background: "rgba(99, 102, 241, 0.1)",
},
}}
>
{showScriptFormatInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Stack>
<Collapse in={showScriptFormatInfo}>
<Stack spacing={2.5}>
<Box>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.8, mb: 2 }}>
Our AI script generator creates scripts specifically optimized for <strong style={{ fontWeight: 600 }}>high-quality text-to-speech</strong>.
The format you see here is designed to produce audio that sounds natural and human-like, not robotic.
</Typography>
</Box>
<Stack spacing={2}>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
1
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Natural Pauses & Rhythm
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
The script includes strategic pauses between lines and when speakers change. This creates natural breathing patterns
and conversation flow, just like real human speech. Without these pauses, the audio would sound rushed and robotic.
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
2
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Emphasis Markers
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
Lines marked with emphasis help highlight important points, statistics, or key insights. The AI voice will naturally
stress these parts, making your podcast more engaging and easier to followjust like a real host would emphasize important information.
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
3
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Short, Conversational Sentences
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
The script uses shorter sentences (15-20 words) written in a conversational style. This matches how people actually
speak, making the audio sound more natural. Long, complex sentences would sound awkward when spoken aloud.
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
4
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Scene-Specific Emotions
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
Each scene has an emotional tone (excited, serious, curious, etc.) that guides the AI voice's delivery. This creates
variety and keeps listeners engaged, just like a real podcast host would vary their tone based on the topic.
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
5
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Optimized for Podcast Narration
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
The script is optimized with slightly slower pacing and natural pronunciation settings specifically for podcast narration.
This ensures clarity and makes the content easy to understand, even when listeners are multitasking.
</Typography>
</Box>
</Box>
</Stack>
<Alert
severity="info"
sx={{
mt: 1,
background: "rgba(99, 102, 241, 0.06)",
border: "1px solid rgba(99, 102, 241, 0.15)",
"& .MuiAlert-icon": {
color: "#6366f1",
},
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
<strong style={{ fontWeight: 600 }}>Tip:</strong> You can edit any line or scene to match your preferences.
The format will be preserved when rendering, ensuring your audio still sounds natural and professional.
</Typography>
</Alert>
</Stack>
</Collapse>
</Paper>
<Alert
severity="info"
sx={{
@@ -693,10 +327,10 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
<BrollInfoPanel
activeScript={script}
generatingChartId={generatingChartId}
generateChartPreviews={generateChartPreviews}
regenerateChart={regenerateChart}
removeChart={removeChart}
generatingChartId={undefined}
generateChartPreviews={undefined}
regenerateChart={undefined}
removeChart={undefined}
scenesWithCharts={script.scenes.filter((s) => s.chart_data && Object.keys(s.chart_data).length > 0).length}
/>
@@ -717,6 +351,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
approvingSceneId={approvingSceneId}
generatingAudioId={generatingAudioId}
totalScenes={script.scenes.length}
sceneIndex={idx}
onAudioGenerationStart={(sceneId) => {
setGeneratingAudioId(sceneId);
}}

View File

@@ -1,7 +1,7 @@
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
import { Script, Knobs, Scene, PodcastMode } from "../types";
import { podcastApi } from "../../../services/podcastApi";
import { getApiUrl } from "../../../api/client";
import { podcastApi, getCachedVoiceCloneInfo } from "../../../services/podcastApi";
import { getApiUrl, getAuthTokenGetter } from "../../../api/client";
interface ScriptEditorContextType {
// State
@@ -63,9 +63,23 @@ const toUsablePreviewUrl = (previewUrl?: string): string | undefined => {
if (!previewUrl) return undefined;
if (/^https?:\/\//i.test(previewUrl)) return previewUrl;
const cleanPath = previewUrl.startsWith("/") ? previewUrl : `/${previewUrl}`;
// Build base URL — auth token will be appended lazily when the URL is used
return `${getApiUrl()}${cleanPath}`;
};
const appendAuthToken = async (url: string): Promise<string> => {
const tokenGetter = getAuthTokenGetter();
if (!tokenGetter) return url;
try {
const token = await tokenGetter();
if (token) {
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}token=${encodeURIComponent(token)}`;
}
} catch {}
return url;
};
interface ScriptEditorProviderProps {
children: ReactNode;
projectId: string;
@@ -316,13 +330,14 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
lines: scene.lines.map((line) => ({ text: line.text })),
}));
const cachedClone = getCachedVoiceCloneInfo();
const result = await podcastApi.generateBatchAudio({
scenes: sceneData,
voiceId: knobs.voice_id,
customVoiceId: knobs.custom_voice_id,
useVoiceClone: knobs.is_voice_clone,
voiceSampleUrl: knobs.voice_sample_url,
voiceCloneEngine: knobs.voice_clone_engine,
customVoiceId: knobs.custom_voice_id || cachedClone?.customVoiceId,
useVoiceClone: knobs.is_voice_clone || cachedClone?.isVoiceClone || false,
voiceSampleUrl: knobs.voice_sample_url || cachedClone?.voiceSampleUrl || undefined,
voiceCloneEngine: knobs.voice_clone_engine || cachedClone?.engine || undefined,
speed: knobs.voice_speed,
emotion: knobs.voice_emotion,
englishNormalization: true,
@@ -423,9 +438,12 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
title: scene.title,
});
const baseUrl = toUsablePreviewUrl(result.preview_url);
const authUrl = baseUrl ? await appendAuthToken(baseUrl) : undefined;
return {
...scene,
broll_preview_url: toUsablePreviewUrl(result.preview_url),
broll_preview_url: authUrl,
chart_id: result.chart_id,
};
} catch (err) {
@@ -461,9 +479,12 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
title: scene.title,
});
const baseUrl = toUsablePreviewUrl(result.preview_url);
const authUrl = baseUrl ? await appendAuthToken(baseUrl) : undefined;
const updatedScenes = activeScript.scenes.map((s) =>
s.id === sceneId
? { ...s, broll_preview_url: toUsablePreviewUrl(result.preview_url), chart_id: result.chart_id }
? { ...s, broll_preview_url: authUrl, chart_id: result.chart_id }
: s
);

View File

@@ -1,6 +1,6 @@
import React from "react";
import { Stack, Box, Typography, Paper, Button, CircularProgress, Chip, IconButton, Tooltip } from "@mui/material";
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon, Fullscreen as FullscreenIcon, Visibility as VisibilityIcon } from "@mui/icons-material";
import React, { useState } from "react";
import { Stack, Box, Typography, Paper, Button, CircularProgress, Chip, IconButton, Tooltip, Accordion, AccordionSummary, AccordionDetails, Dialog, DialogContent, DialogTitle } from "@mui/material";
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon, Fullscreen as FullscreenIcon, ExpandMore as ExpandMoreIcon, Close as CloseIcon, ZoomOutMap as ZoomOutMapIcon } from "@mui/icons-material";
import { useScriptEditor } from "../ScriptEditorContext";
import { Script } from "../../types";
@@ -14,6 +14,8 @@ interface BrollInfoPanelProps {
}
export const BrollInfoPanel: React.FC<BrollInfoPanelProps> = (props) => {
const [expanded, setExpanded] = useState(false);
const [previewModal, setPreviewModal] = useState<{ url: string; title: string } | null>(null);
const ctx = useScriptEditor();
const {
@@ -39,249 +41,363 @@ export const BrollInfoPanel: React.FC<BrollInfoPanelProps> = (props) => {
const hasChartData = scenesWithData.length > 0;
const resolvedScenesWithCharts = props.scenesWithCharts ?? ctxScenesWithCharts ?? scenesWithData.length;
return (
<Paper
sx={{
p: 2.5,
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.03) 0%, rgba(16, 185, 129, 0.03) 100%)",
border: "1px solid rgba(34, 197, 94, 0.15)",
borderRadius: 2,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box sx={{
p: 0.75,
borderRadius: 1.5,
background: "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center"
}}>
<BarChartIcon sx={{ fontSize: 18, color: "#fff" }} />
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: "#0f172a", lineHeight: 1.2 }}>
B-Roll Charts
</Typography>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
{resolvedScenesWithCharts} chart{resolvedScenesWithCharts !== 1 ? 's' : ''} for visual storytelling
</Typography>
</Box>
</Stack>
{hasChartData && (
<Button
variant="contained"
size="small"
startIcon={resolvedGeneratingChartId ? <CircularProgress size={14} color="inherit" /> : <AutoAwesomeIcon sx={{ fontSize: 16 }} />}
onClick={resolvedGenerateChartPreviews}
disabled={!!resolvedGeneratingChartId}
sx={{
background: "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)",
fontSize: "0.75rem",
py: 0.5,
px: 1.5,
textTransform: "none",
fontWeight: 600,
boxShadow: "0 2px 8px rgba(34, 197, 94, 0.3)",
"&:hover": {
background: "linear-gradient(135deg, #16a34a 0%, #15803d 100%)",
},
"&:disabled": {
background: "rgba(34, 197, 94, 0.5)",
}
}}
>
{resolvedGeneratingChartId ? "Generating..." : "Generate Charts"}
</Button>
)}
</Stack>
const openPreview = (url: string, title: string) => {
setPreviewModal({ url, title });
};
{hasChartData ? (
<Stack spacing={1.5}>
{scenesWithData.map((scene) => {
const chartData = scene.chart_data;
const hasPreview = !!scene.broll_preview_url;
return (
<Box
key={scene.id}
const closePreview = () => {
setPreviewModal(null);
};
return (
<>
<Accordion
expanded={expanded}
onChange={(_, isExpanded) => setExpanded(isExpanded)}
sx={{
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.03) 0%, rgba(16, 185, 129, 0.03) 100%)",
border: "1px solid rgba(34, 197, 94, 0.15)",
borderRadius: 2,
'&:before': {
display: 'none',
},
'&.MuiAccordion-root': {
borderRadius: 2,
},
'& .MuiAccordionSummary-root': {
borderRadius: 2,
},
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon sx={{ color: '#22c55e' }} />}
sx={{
'& .MuiAccordionSummary-content': {
alignItems: 'center',
justifyContent: 'space-between',
},
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box sx={{
p: 0.75,
borderRadius: 1.5,
background: "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center"
}}>
<BarChartIcon sx={{ fontSize: 18, color: "#fff" }} />
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: "#0f172a", lineHeight: 1.2 }}>
Podcast Charts
</Typography>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
{resolvedScenesWithCharts} chart{resolvedScenesWithCharts !== 1 ? 's' : ''} for visual storytelling
</Typography>
</Box>
</Stack>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
{hasChartData && (
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
size="small"
startIcon={resolvedGeneratingChartId ? <CircularProgress size={14} color="inherit" /> : <AutoAwesomeIcon sx={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
resolvedGenerateChartPreviews?.();
}}
disabled={!!resolvedGeneratingChartId}
sx={{
p: 1.5,
background: "#fff",
borderRadius: 1.5,
border: "1px solid rgba(0,0,0,0.06)",
display: "flex",
alignItems: "center",
gap: 2,
transition: "all 0.2s ease",
background: "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)",
fontSize: "0.75rem",
py: 0.5,
px: 1.5,
textTransform: "none",
fontWeight: 600,
boxShadow: "0 2px 8px rgba(34, 197, 94, 0.3)",
"&:hover": {
borderColor: "rgba(34, 197, 94, 0.3)",
boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
background: "linear-gradient(135deg, #16a34a 0%, #15803d 100%)",
},
"&:disabled": {
background: "rgba(34, 197, 94, 0.5)",
}
}}
>
{/* Thumbnail */}
<Box
sx={{
width: 72,
height: 48,
flexShrink: 0,
borderRadius: 1,
overflow: "hidden",
background: hasPreview ? "rgba(0,0,0,0.04)" : "linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: hasPreview ? "pointer" : "default",
transition: "all 0.2s ease",
"&:hover": hasPreview ? {
transform: "scale(1.05)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
} : {}
}}
>
{resolvedGeneratingChartId === scene.id ? (
<CircularProgress size={24} sx={{ color: "#22c55e" }} />
) : hasPreview && scene.broll_preview_url ? (
{resolvedGeneratingChartId ? "Generating..." : "Generate Charts"}
</Button>
</Box>
)}
{hasChartData ? (
<Stack spacing={1.5}>
{scenesWithData.map((scene) => {
const chartData = scene.chart_data;
const hasPreview = !!scene.broll_preview_url;
return (
<Box
key={scene.id}
sx={{
p: 1.5,
background: "#fff",
borderRadius: 1.5,
border: "1px solid rgba(0,0,0,0.06)",
display: "flex",
alignItems: "center",
gap: 2,
transition: "all 0.2s ease",
"&:hover": {
borderColor: "rgba(34, 197, 94, 0.3)",
boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
}
}}
>
{/* Thumbnail */}
<Box
component="img"
src={scene.broll_preview_url}
alt={`Chart for ${scene.title}`}
onClick={() => hasPreview && scene.broll_preview_url && openPreview(scene.broll_preview_url, scene.title)}
sx={{
width: "100%",
height: "100%",
objectFit: "cover",
width: 72,
height: 48,
flexShrink: 0,
borderRadius: 1,
overflow: "hidden",
background: hasPreview ? "rgba(0,0,0,0.04)" : "linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: hasPreview ? "pointer" : "default",
transition: "all 0.2s ease",
position: "relative",
"&:hover": hasPreview ? {
transform: "scale(1.05)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
"& .zoom-overlay": {
opacity: 1,
},
} : {}
}}
onClick={() => window.open(scene.broll_preview_url, '_blank')}
/>
) : (
<BarChartIcon sx={{ fontSize: 20, color: "#94a3b8" }} />
)}
</Box>
>
{resolvedGeneratingChartId === scene.id ? (
<CircularProgress size={24} sx={{ color: "#22c55e" }} />
) : hasPreview && scene.broll_preview_url ? (
<>
<Box
component="img"
src={scene.broll_preview_url}
alt={`Chart for ${scene.title}`}
sx={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
<Box
className="zoom-overlay"
sx={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: 0,
transition: "opacity 0.2s ease",
borderRadius: 1,
}}
>
<ZoomOutMapIcon sx={{ color: "#fff", fontSize: 18 }} />
</Box>
</>
) : (
<BarChartIcon sx={{ fontSize: 20, color: "#94a3b8" }} />
)}
</Box>
{/* Chart Info */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{
fontWeight: 600,
color: "#1e293b",
fontSize: "0.8rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{scene.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.25 }}>
<Chip
label={chartData?.type || "chart"}
size="small"
sx={{
height: 18,
fontSize: "0.65rem",
background: "rgba(34, 197, 94, 0.1)",
color: "#16a34a",
fontWeight: 600,
}}
/>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
{chartData?.labels?.length || 0} labels
</Typography>
{hasPreview && (
<Chip
label="Ready"
size="small"
sx={{
height: 18,
fontSize: "0.65rem",
background: "rgba(34, 197, 94, 0.15)",
color: "#16a34a",
fontWeight: 600,
}}
/>
{/* Chart Info */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{
fontWeight: 600,
color: "#1e293b",
fontSize: "0.8rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{scene.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.25 }}>
<Chip
label={chartData?.type || "chart"}
size="small"
sx={{
height: 18,
fontSize: "0.65rem",
background: "rgba(34, 197, 94, 0.1)",
color: "#16a34a",
fontWeight: 600,
}}
/>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
{chartData?.labels?.length || 0} labels
</Typography>
{hasPreview && (
<Chip
label="Ready"
size="small"
sx={{
height: 18,
fontSize: "0.65rem",
background: "rgba(34, 197, 94, 0.15)",
color: "#16a34a",
fontWeight: 600,
}}
/>
)}
</Stack>
</Box>
{/* Takeaway */}
{chartData?.takeaway && (
<Box sx={{
flex: 1.5,
display: { xs: "none", md: "block" },
px: 1,
py: 0.5,
background: "rgba(34, 197, 94, 0.04)",
borderRadius: 1,
}}>
<Typography variant="caption" sx={{
color: "#475569",
fontSize: "0.7rem",
fontStyle: "italic",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}>
"{chartData.takeaway}"
</Typography>
</Box>
)}
</Stack>
</Box>
{/* Takeaway */}
{chartData?.takeaway && (
<Box sx={{
flex: 1.5,
display: { xs: "none", md: "block" },
px: 1,
py: 0.5,
background: "rgba(34, 197, 94, 0.04)",
borderRadius: 1,
}}>
<Typography variant="caption" sx={{
color: "#475569",
fontSize: "0.7rem",
fontStyle: "italic",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}>
"{chartData.takeaway}"
</Typography>
{/* Actions */}
<Stack direction="row" spacing={0.5}>
{hasPreview && (
<Tooltip title="View fullsize">
<IconButton
size="small"
onClick={() => scene.broll_preview_url && openPreview(scene.broll_preview_url, scene.title)}
sx={{
color: "#64748b",
"&:hover": { color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }
}}
>
<FullscreenIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
)}
<Tooltip title="Regenerate">
<IconButton
size="small"
onClick={() => resolvedRegenerateChart?.(scene.id)}
disabled={!resolvedRegenerateChart || !!resolvedGeneratingChartId}
sx={{
color: "#64748b",
"&:hover": { color: "#f59e0b", background: "rgba(245, 158, 11, 0.1)" }
}}
>
<RefreshIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Remove chart">
<IconButton
size="small"
onClick={() => resolvedRemoveChart?.(scene.id)}
disabled={!resolvedRemoveChart}
sx={{
color: "#64748b",
"&:hover": { color: "#ef4444", background: "rgba(239, 68, 68, 0.1)" }
}}
>
<DeleteIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
</Stack>
</Box>
)}
);
})}
</Stack>
) : (
<Box sx={{ py: 3, textAlign: "center" }}>
<BarChartIcon sx={{ fontSize: 36, color: "#cbd5e1", mb: 1 }} />
<Typography variant="body2" sx={{ color: "#64748b", fontSize: "0.8rem" }}>
No chart data yet. Add chart data to scenes to generate B-roll visuals.
</Typography>
</Box>
)}
</AccordionDetails>
</Accordion>
{/* Actions */}
<Stack direction="row" spacing={0.5}>
{hasPreview && (
<Tooltip title="View fullsize">
<IconButton
size="small"
onClick={() => scene.broll_preview_url && window.open(scene.broll_preview_url, '_blank')}
sx={{
color: "#64748b",
"&:hover": { color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }
}}
>
<FullscreenIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
)}
<Tooltip title="Regenerate">
<IconButton
size="small"
onClick={() => resolvedRegenerateChart?.(scene.id)}
disabled={!resolvedRegenerateChart || !!resolvedGeneratingChartId}
sx={{
color: "#64748b",
"&:hover": { color: "#f59e0b", background: "rgba(245, 158, 11, 0.1)" }
}}
>
<RefreshIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Remove chart">
<IconButton
size="small"
onClick={() => resolvedRemoveChart?.(scene.id)}
disabled={!resolvedRemoveChart}
sx={{
color: "#64748b",
"&:hover": { color: "#ef4444", background: "rgba(239, 68, 68, 0.1)" }
}}
>
<DeleteIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
</Stack>
</Box>
);
})}
</Stack>
) : (
<Box sx={{ py: 3, textAlign: "center" }}>
<BarChartIcon sx={{ fontSize: 36, color: "#cbd5e1", mb: 1 }} />
<Typography variant="body2" sx={{ color: "#64748b", fontSize: "0.8rem" }}>
No chart data yet. Add chart data to scenes to generate B-roll visuals.
</Typography>
</Box>
)}
</Paper>
{/* Full-size chart preview modal */}
<Dialog
open={!!previewModal}
onClose={closePreview}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
background: "#0f172a",
overflow: "hidden",
}
}}
>
{previewModal && (
<>
<DialogTitle sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
color: "#f1f5f9",
py: 1.5,
px: 2,
}}>
<Stack direction="row" alignItems="center" spacing={1}>
<BarChartIcon sx={{ fontSize: 20, color: "#22c55e" }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: "#f1f5f9" }}>
{previewModal.title}
</Typography>
</Stack>
<IconButton
onClick={closePreview}
size="small"
sx={{ color: "#94a3b8", "&:hover": { color: "#f1f5f9" } }}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: 0, display: "flex", justifyContent: "center", alignItems: "center", minHeight: 300, background: "#0f172a" }}>
<Box
component="img"
src={previewModal.url}
alt={`Chart: ${previewModal.title}`}
sx={{
maxWidth: "100%",
maxHeight: "70vh",
objectFit: "contain",
p: 2,
}}
/>
</DialogContent>
</>
)}
</Dialog>
</>
);
};

View File

@@ -85,6 +85,8 @@ export type Scene = {
imagePrompt?: string;
chart_data?: Record<string, any>;
broll_preview_url?: string;
broll_video_url?: string;
chart_id?: string;
};
export type Script = {

View File

@@ -70,6 +70,9 @@ const DEFAULT_KNOBS: Knobs = {
voice_speed: 1,
voice_id: "Wise_Woman",
custom_voice_id: undefined,
is_voice_clone: undefined,
voice_sample_url: undefined,
voice_clone_engine: undefined,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
@@ -443,7 +446,7 @@ export const usePodcastProjectState = () => {
scriptData: dbProject.script_data,
bible: dbProject.bible,
renderJobs: dbProject.render_jobs || [],
knobs: dbProject.knobs || DEFAULT_KNOBS,
knobs: { ...DEFAULT_KNOBS, ...(dbProject.knobs || {}) },
researchProvider: dbProject.research_provider || 'exa',
budgetCap: dbProject.budget_cap || 50,
showScriptEditor: dbProject.show_script_editor || false,

View File

@@ -626,11 +626,14 @@ export const podcastApi = {
text: textToUse,
voice_id: params.voiceId || "Wise_Woman",
custom_voice_id: params.customVoiceId || null,
speed: params.speed ?? 1.0, // Normal speed (was 0.9, but too slow - causing duration issues)
use_voice_clone: params.useVoiceClone || false,
voice_sample_url: params.voiceSampleUrl || null,
voice_clone_engine: params.voiceCloneEngine || null,
speed: params.speed ?? 1.0,
volume: params.volume ?? 1.0,
pitch: params.pitch ?? 0.0,
emotion: sceneEmotion,
english_normalization: params.englishNormalization ?? true, // Better number reading for statistics
english_normalization: params.englishNormalization ?? true,
sample_rate: params.sampleRate || null,
bitrate: params.bitrate || null,
channel: params.channel || null,