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:
@@ -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,
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 follow—just like a real host would emphasize important information.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
3
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Short, Conversational Sentences
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
The script uses shorter sentences (15-20 words) written in a conversational style. This matches how people actually
|
||||
speak, making the audio sound more natural. Long, complex sentences would sound awkward when spoken aloud.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
4
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Scene-Specific Emotions
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
Each scene has an emotional tone (excited, serious, curious, etc.) that guides the AI voice's delivery. This creates
|
||||
variety and keeps listeners engaged, just like a real podcast host would vary their tone based on the topic.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
5
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Optimized for Podcast Narration
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
The script is optimized with slightly slower pacing and natural pronunciation settings specifically for podcast narration.
|
||||
This ensures clarity and makes the content easy to understand, even when listeners are multitasking.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mt: 1,
|
||||
background: "rgba(99, 102, 241, 0.06)",
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
"& .MuiAlert-icon": {
|
||||
color: "#6366f1",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
|
||||
<strong style={{ fontWeight: 600 }}>Tip:</strong> You can edit any line or scene to match your preferences.
|
||||
The format will be preserved when rendering, ensuring your audio still sounds natural and professional.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user