AI podcast project

This commit is contained in:
ajaysi
2025-12-16 16:25:52 +05:30
parent eba5210577
commit 1d745c9bc8
50 changed files with 7637 additions and 2813 deletions

View File

@@ -0,0 +1,464 @@
import React, { useEffect, useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
Box,
Typography,
Slider,
Select,
MenuItem,
FormControl,
InputLabel,
FormControlLabel,
Checkbox,
Tooltip,
IconButton,
alpha,
TextField,
} from "@mui/material";
import { HelpOutline as HelpOutlineIcon, Close as CloseIcon } from "@mui/icons-material";
import { PrimaryButton, SecondaryButton } from "../ui";
export type AudioGenerationSettings = {
voiceId: string;
speed: number;
volume: number;
pitch: number;
emotion: string;
englishNormalization: boolean;
sampleRate?: number;
bitrate?: number;
channel?: "1" | "2";
format?: "mp3" | "wav" | "pcm" | "flac";
languageBoost?: string;
};
interface AudioRegenerateModalProps {
open: boolean;
onClose: () => void;
onRegenerate: (settings: AudioGenerationSettings) => void;
initialSettings: AudioGenerationSettings;
isGenerating?: boolean;
}
const VOICE_OPTIONS = [
"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",
];
const EMOTION_OPTIONS = ["happy", "sad", "angry", "fearful", "disgusted", "surprised", "neutral"];
const SAMPLE_RATE_OPTIONS = [8000, 16000, 22050, 24000, 32000, 44100];
const BITRATE_OPTIONS = [32000, 64000, 128000, 256000];
const LANGUAGE_BOOST_OPTIONS = [
"auto",
"English",
"Chinese",
"Chinese,Yue",
"Arabic",
"Russian",
"Spanish",
"French",
"Portuguese",
"German",
"Turkish",
"Dutch",
"Ukrainian",
"Vietnamese",
"Indonesian",
"Japanese",
"Italian",
"Korean",
"Thai",
"Polish",
"Romanian",
"Greek",
"Czech",
"Finnish",
"Hindi",
];
export const AudioRegenerateModal: React.FC<AudioRegenerateModalProps> = ({
open,
onClose,
onRegenerate,
initialSettings,
isGenerating = false,
}) => {
const [settings, setSettings] = useState<AudioGenerationSettings>(initialSettings);
useEffect(() => {
setSettings(initialSettings);
}, [initialSettings]);
const handleRegenerate = () => {
onRegenerate(settings);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
background: alpha("#0f172a", 0.95),
backdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 4,
},
}}
>
<DialogTitle>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h6" sx={{ color: "white", fontWeight: 600 }}>
Regenerate Audio with Custom Settings
</Typography>
<IconButton onClick={onClose} size="small" sx={{ color: "rgba(255,255,255,0.7)" }}>
<CloseIcon />
</IconButton>
</Stack>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.6)", mt: 1 }}>
Adjust voice, speed, tone, and quality. Changes apply only to this scene.
</Typography>
</DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
{/* Voice */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Voice
</Typography>
<Tooltip title="Choose a system voice or your custom trained voice ID." arrow>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={settings.voiceId}
onChange={(e) => setSettings({ ...settings, voiceId: e.target.value })}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused .MuiOutlinedInput-notchedOutline": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
}}
>
{VOICE_OPTIONS.map((v) => (
<MenuItem key={v} value={v}>
{v}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{/* Speed / Volume / Pitch */}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Box sx={{ flex: 1 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ color: "white", fontWeight: 600 }}>
Speed (0.5-2.0)
</Typography>
<Tooltip title="Control how fast the voice speaks. 1.0 is normal." arrow>
<HelpOutlineIcon fontSize="small" sx={{ color: "rgba(255,255,255,0.5)" }} />
</Tooltip>
</Stack>
<Slider
value={settings.speed}
min={0.5}
max={2.0}
step={0.05}
onChange={(_, v) => setSettings({ ...settings, speed: v as number })}
sx={{ color: "#6366f1" }}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Slower (narrative) Faster (conversational). Impacts duration.
</Typography>
</Box>
<Box sx={{ flex: 1 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ color: "white", fontWeight: 600 }}>
Volume (0.1-10)
</Typography>
<Tooltip title="Loudness of the voice. 1.0 is normal loudness." arrow>
<HelpOutlineIcon fontSize="small" sx={{ color: "rgba(255,255,255,0.5)" }} />
</Tooltip>
</Stack>
<Slider
value={settings.volume}
min={0.1}
max={10}
step={0.1}
onChange={(_, v) => setSettings({ ...settings, volume: v as number })}
sx={{ color: "#10b981" }}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Lower for soft tone; higher for punchier delivery.
</Typography>
</Box>
<Box sx={{ flex: 1 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ color: "white", fontWeight: 600 }}>
Pitch (-12 to 12)
</Typography>
<Tooltip title="Tone of the voice. 0 is neutral. Negative is deeper; positive is brighter." arrow>
<HelpOutlineIcon fontSize="small" sx={{ color: "rgba(255,255,255,0.5)" }} />
</Tooltip>
</Stack>
<Slider
value={settings.pitch}
min={-12}
max={12}
step={0.5}
onChange={(_, v) => setSettings({ ...settings, pitch: v as number })}
sx={{ color: "#f97316" }}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Use small adjustments (±2) for natural results.
</Typography>
</Box>
</Stack>
{/* Emotion */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Emotion
</Typography>
<Tooltip title="Sets the vocal mood: happy, neutral, sad, etc." arrow>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={settings.emotion}
onChange={(e) => setSettings({ ...settings, emotion: e.target.value })}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused .MuiOutlinedInput-notchedOutline": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
}}
>
{EMOTION_OPTIONS.map((e) => (
<MenuItem key={e} value={e}>
{e}
</MenuItem>
))}
</Select>
</FormControl>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", mt: 0.5, display: "block" }}>
Tip: happy/neutral for most podcasts; sad/angry for dramatic or critical segments.
</Typography>
</Box>
{/* Normalization & Language */}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Box sx={{ flex: 1 }}>
<FormControlLabel
control={
<Checkbox
checked={settings.englishNormalization}
onChange={(e) => setSettings({ ...settings, englishNormalization: e.target.checked })}
sx={{ color: "rgba(255,255,255,0.7)" }}
/>
}
label={
<Typography variant="body2" sx={{ color: "white" }}>
English normalization (better numbers/dates)
</Typography>
}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Improves pronunciation of numbers/dates (recommended for stats-heavy scenes).
</Typography>
</Box>
<Box sx={{ flex: 1 }}>
<TextField
select
fullWidth
label="Language boost"
value={settings.languageBoost || "auto"}
onChange={(e) => setSettings({ ...settings, languageBoost: e.target.value })}
SelectProps={{ native: false }}
InputLabelProps={{ sx: { color: "rgba(255,255,255,0.7)" } }}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused fieldset": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
},
}}
>
{LANGUAGE_BOOST_OPTIONS.map((opt) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</TextField>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", mt: 0.5, display: "block" }}>
Helps with language-specific pronunciation and accent.
</Typography>
</Box>
</Stack>
{/* Quality & Format */}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Box sx={{ flex: 1 }}>
<TextField
select
fullWidth
label="Sample rate"
value={settings.sampleRate || 24000}
onChange={(e) => setSettings({ ...settings, sampleRate: Number(e.target.value) })}
InputLabelProps={{ sx: { color: "rgba(255,255,255,0.7)" } }}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused fieldset": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
},
}}
>
{SAMPLE_RATE_OPTIONS.map((opt) => (
<MenuItem key={opt} value={opt}>
{opt} Hz
</MenuItem>
))}
</TextField>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", mt: 0.5, display: "block" }}>
Higher sample rate = higher fidelity (24k+ recommended for podcast voice).
</Typography>
</Box>
<Box sx={{ flex: 1 }}>
<TextField
select
fullWidth
label="Bitrate"
value={settings.bitrate || 64000}
onChange={(e) => setSettings({ ...settings, bitrate: Number(e.target.value) })}
InputLabelProps={{ sx: { color: "rgba(255,255,255,0.7)" } }}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused fieldset": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
},
}}
>
{BITRATE_OPTIONS.map((opt) => (
<MenuItem key={opt} value={opt}>
{opt / 1000} kbps
</MenuItem>
))}
</TextField>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", mt: 0.5, display: "block" }}>
Higher bitrate = larger file but clearer audio. 64128 kbps is great for voice.
</Typography>
</Box>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Box sx={{ flex: 1 }}>
<TextField
select
fullWidth
label="Channel"
value={settings.channel || "1"}
onChange={(e) => setSettings({ ...settings, channel: e.target.value as "1" | "2" })}
InputLabelProps={{ sx: { color: "rgba(255,255,255,0.7)" } }}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused fieldset": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
},
}}
>
<MenuItem value="1">Mono (smaller, voice-focused)</MenuItem>
<MenuItem value="2">Stereo (wider, more presence)</MenuItem>
</TextField>
</Box>
<Box sx={{ flex: 1 }}>
<TextField
select
fullWidth
label="Format"
value={settings.format || "mp3"}
onChange={(e) => setSettings({ ...settings, format: e.target.value as "mp3" | "wav" | "pcm" | "flac" })}
InputLabelProps={{ sx: { color: "rgba(255,255,255,0.7)" } }}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused fieldset": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
},
}}
>
<MenuItem value="mp3">mp3 (small, universal)</MenuItem>
<MenuItem value="wav">wav (uncompressed)</MenuItem>
<MenuItem value="pcm">pcm (raw)</MenuItem>
<MenuItem value="flac">flac (lossless)</MenuItem>
</TextField>
</Box>
</Stack>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 2 }}>
<SecondaryButton onClick={onClose} disabled={isGenerating}>
Cancel
</SecondaryButton>
<PrimaryButton onClick={handleRegenerate} loading={isGenerating} disabled={isGenerating}>
{isGenerating ? "Generating..." : "Apply & Regenerate"}
</PrimaryButton>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,563 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
Box,
Typography,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Divider,
alpha,
Tooltip,
IconButton,
Paper,
} from "@mui/material";
import {
Info as InfoIcon,
HelpOutline as HelpOutlineIcon,
Close as CloseIcon,
} from "@mui/icons-material";
import { PrimaryButton, SecondaryButton } from "../ui";
type PresetKey = "studioNeutral" | "warmBroadcast" | "techModern";
const PRESETS: Record<
PresetKey,
{
title: string;
subtitle: string;
prompt: string;
style: "Auto" | "Fiction" | "Realistic";
renderingSpeed: "Default" | "Turbo" | "Quality";
aspectRatio: "1:1" | "16:9" | "9:16" | "4:3" | "3:4";
}
> = {
studioNeutral: {
title: "Studio Neutral",
subtitle: "Clean, well-lit studio, neutral background",
prompt:
"Professional podcast studio, neutral light grey backdrop, soft key + fill lighting, subtle depth of field, clear microphone framing",
style: "Realistic",
renderingSpeed: "Quality",
aspectRatio: "16:9",
},
warmBroadcast: {
title: "Warm Broadcast",
subtitle: "Warm tones, friendly and inviting broadcast desk",
prompt:
"Warm broadcast desk, soft amber lighting, cozy ambience, gentle vignette, inviting expression, polished but approachable look",
style: "Realistic",
renderingSpeed: "Quality",
aspectRatio: "16:9",
},
techModern: {
title: "Tech Modern",
subtitle: "Crisp, modern look with cool accent lighting",
prompt:
"Modern tech podcast set, cool accent lights (teal/purple), minimal backdrop, crisp highlights, premium camera look, subtle bokeh",
style: "Auto",
renderingSpeed: "Quality",
aspectRatio: "16:9",
},
};
export interface ImageGenerationSettings {
prompt: string;
style: "Auto" | "Fiction" | "Realistic";
renderingSpeed: "Default" | "Turbo" | "Quality";
aspectRatio: "1:1" | "16:9" | "9:16" | "4:3" | "3:4";
}
interface ImageRegenerateModalProps {
open: boolean;
onClose: () => void;
onRegenerate: (settings: ImageGenerationSettings) => void;
initialPrompt: string;
initialStyle?: "Auto" | "Fiction" | "Realistic";
initialRenderingSpeed?: "Default" | "Turbo" | "Quality";
initialAspectRatio?: "1:1" | "16:9" | "9:16" | "4:3" | "3:4";
isGenerating?: boolean;
}
export const ImageRegenerateModal: React.FC<ImageRegenerateModalProps> = ({
open,
onClose,
onRegenerate,
initialPrompt,
initialStyle = "Realistic",
initialRenderingSpeed = "Quality",
initialAspectRatio = "16:9",
isGenerating = false,
}) => {
const [prompt, setPrompt] = useState(initialPrompt);
const [style, setStyle] = useState<"Auto" | "Fiction" | "Realistic">(initialStyle);
const [renderingSpeed, setRenderingSpeed] = useState<"Default" | "Turbo" | "Quality">(initialRenderingSpeed);
const [aspectRatio, setAspectRatio] = useState<"1:1" | "16:9" | "9:16" | "4:3" | "3:4">(initialAspectRatio);
// Update state when initial values change
useEffect(() => {
setPrompt(initialPrompt);
setStyle(initialStyle);
setRenderingSpeed(initialRenderingSpeed);
setAspectRatio(initialAspectRatio);
}, [initialPrompt, initialStyle, initialRenderingSpeed, initialAspectRatio]);
const handleRegenerate = () => {
onRegenerate({
prompt,
style,
renderingSpeed,
aspectRatio,
});
};
const applyPreset = (presetKey: PresetKey) => {
const p = PRESETS[presetKey];
// Combine the preset prompt with current scene prompt context
setPrompt((current) => {
// If user already customized, append; otherwise replace with preset
if (!current || current.trim() === "" || current.trim() === initialPrompt.trim()) {
return `${initialPrompt}\n${p.prompt}`.trim();
}
return `${current}\n${p.prompt}`.trim();
});
setStyle(p.style);
setRenderingSpeed(p.renderingSpeed);
setAspectRatio(p.aspectRatio);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
background: alpha("#0f172a", 0.95),
backdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 4,
},
}}
>
<DialogTitle>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h6" sx={{ color: "white", fontWeight: 600 }}>
Regenerate Image with Custom Settings
</Typography>
<IconButton
onClick={onClose}
size="small"
sx={{ color: "rgba(255,255,255,0.7)" }}
>
<CloseIcon />
</IconButton>
</Stack>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.6)", mt: 1 }}>
Customize the image generation parameters to get the perfect result for your scene
</Typography>
</DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
{/* Presets */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Podcast-ready presets
</Typography>
<Tooltip
title="Quickly apply a podcast-friendly look. Each preset adjusts lighting, background, and ratio while keeping your base avatar consistent."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
{(
Object.entries(PRESETS) as Array<[PresetKey, (typeof PRESETS)[PresetKey]]>
).map(([key, p]) => (
<Paper
key={key}
onClick={() => applyPreset(key)}
sx={{
p: 1.5,
flex: 1,
cursor: "pointer",
backgroundColor: alpha("#ffffff", 0.04),
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 2,
transition: "all 0.2s ease",
"&:hover": {
borderColor: "rgba(102,126,234,0.7)",
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
backgroundColor: alpha("#667eea", 0.08),
},
}}
>
<Typography variant="subtitle2" sx={{ color: "white", fontWeight: 700 }}>
{p.title}
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", lineHeight: 1.5, mb: 0.75 }}>
{p.subtitle}
</Typography>
<Stack direction="row" spacing={1} sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.8rem" }}>
<Typography variant="caption">Style: {p.style}</Typography>
<Typography variant="caption">Speed: {p.renderingSpeed}</Typography>
<Typography variant="caption">AR: {p.aspectRatio}</Typography>
</Stack>
</Paper>
))}
</Stack>
</Box>
{/* Prompt Section */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Generation Prompt
</Typography>
<Tooltip
title="The prompt describes what you want to see in the generated image. It should include scene context, visual elements, and style preferences. The AI will use this along with your base avatar to create a consistent character in the scene."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<TextField
fullWidth
multiline
rows={4}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe the scene, visual elements, and style..."
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": {
borderColor: "rgba(255,255,255,0.2)",
},
"&:hover fieldset": {
borderColor: "rgba(255,255,255,0.3)",
},
"&.Mui-focused fieldset": {
borderColor: "#667eea",
},
},
"& .MuiInputBase-input": {
color: "white",
},
}}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.5)", mt: 0.5, display: "block" }}>
This prompt will be combined with scene context to generate your image. Be specific about visual elements, mood, and composition.
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
{/* Style Selection */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Character Style
</Typography>
<Tooltip
title="Determines the artistic style of the character generation. Auto lets the AI choose, Fiction creates more stylized/artistic characters, and Realistic produces photorealistic results."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={style}
onChange={(e) => setStyle(e.target.value as "Auto" | "Fiction" | "Realistic")}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.2)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.3)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "#667eea",
},
"& .MuiSvgIcon-root": {
color: "rgba(255,255,255,0.7)",
},
}}
>
<MenuItem value="Auto">
<Stack>
<Typography sx={{ color: "white" }}>Auto</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
AI automatically selects the best style
</Typography>
</Stack>
</MenuItem>
<MenuItem value="Fiction">
<Stack>
<Typography sx={{ color: "white" }}>Fiction</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Stylized, artistic character appearance
</Typography>
</Stack>
</MenuItem>
<MenuItem value="Realistic">
<Stack>
<Typography sx={{ color: "white" }}>Realistic</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Photorealistic, professional appearance
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Paper
sx={{
mt: 1.5,
p: 1.5,
backgroundColor: alpha("#667eea", 0.1),
border: "1px solid rgba(102,126,234,0.3)",
borderRadius: 2,
}}
>
<Stack direction="row" spacing={1}>
<InfoIcon sx={{ color: "#667eea", fontSize: "1.2rem", mt: 0.1 }} />
<Box>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)", fontWeight: 500, mb: 0.5 }}>
Style Impact:
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", lineHeight: 1.6 }}>
<strong>Auto:</strong> Best for most cases, balances realism and style<br />
<strong>Fiction:</strong> Great for creative, artistic podcasts with stylized visuals<br />
<strong>Realistic:</strong> Ideal for professional, corporate, or news-style podcasts
</Typography>
</Box>
</Stack>
</Paper>
</Box>
{/* Rendering Speed */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Rendering Speed
</Typography>
<Tooltip
title="Controls the balance between generation speed, cost, and quality. Turbo is fastest and cheapest but lower quality. Quality is slowest and most expensive but produces the best results. Default provides a balanced approach."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={renderingSpeed}
onChange={(e) => setRenderingSpeed(e.target.value as "Default" | "Turbo" | "Quality")}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.2)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.3)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "#667eea",
},
"& .MuiSvgIcon-root": {
color: "rgba(255,255,255,0.7)",
},
}}
>
<MenuItem value="Turbo">
<Stack>
<Typography sx={{ color: "white" }}>Turbo </Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Fastest (~10-20s) Cheapest Lower quality
</Typography>
</Stack>
</MenuItem>
<MenuItem value="Default">
<Stack>
<Typography sx={{ color: "white" }}>Default </Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Balanced (~30-60s) Moderate cost Good quality
</Typography>
</Stack>
</MenuItem>
<MenuItem value="Quality">
<Stack>
<Typography sx={{ color: "white" }}>Quality </Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Slowest (~60-120s) Most expensive Highest quality
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Paper
sx={{
mt: 1.5,
p: 1.5,
backgroundColor: alpha("#10b981", 0.1),
border: "1px solid rgba(16,185,129,0.3)",
borderRadius: 2,
}}
>
<Stack direction="row" spacing={1}>
<InfoIcon sx={{ color: "#10b981", fontSize: "1.2rem", mt: 0.1 }} />
<Box>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)", fontWeight: 500, mb: 0.5 }}>
Speed vs Quality Trade-off:
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", lineHeight: 1.6 }}>
<strong>Turbo:</strong> Use for quick iterations and testing (~$0.02/image)<br />
<strong>Default:</strong> Best balance for most production use (~$0.04/image)<br />
<strong>Quality:</strong> Use for final, high-quality outputs (~$0.08/image)
</Typography>
</Box>
</Stack>
</Paper>
</Box>
{/* Aspect Ratio */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Aspect Ratio
</Typography>
<Tooltip
title="The width-to-height ratio of the generated image. Choose based on your video format: 16:9 for standard widescreen, 9:16 for vertical/social media, 1:1 for square formats, or 4:3 for traditional formats."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value as "1:1" | "16:9" | "9:16" | "4:3" | "3:4")}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.2)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.3)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "#667eea",
},
"& .MuiSvgIcon-root": {
color: "rgba(255,255,255,0.7)",
},
}}
>
<MenuItem value="16:9">
<Stack>
<Typography sx={{ color: "white" }}>16:9 (Widescreen)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Standard video format, best for YouTube, web
</Typography>
</Stack>
</MenuItem>
<MenuItem value="9:16">
<Stack>
<Typography sx={{ color: "white" }}>9:16 (Vertical)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Mobile/social media format (TikTok, Instagram Stories)
</Typography>
</Stack>
</MenuItem>
<MenuItem value="1:1">
<Stack>
<Typography sx={{ color: "white" }}>1:1 (Square)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Instagram posts, profile images
</Typography>
</Stack>
</MenuItem>
<MenuItem value="4:3">
<Stack>
<Typography sx={{ color: "white" }}>4:3 (Traditional)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Classic TV format, presentations
</Typography>
</Stack>
</MenuItem>
<MenuItem value="3:4">
<Stack>
<Typography sx={{ color: "white" }}>3:4 (Portrait)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Portrait orientation, mobile apps
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Paper
sx={{
mt: 1.5,
p: 1.5,
backgroundColor: alpha("#f59e0b", 0.1),
border: "1px solid rgba(245,158,11,0.3)",
borderRadius: 2,
}}
>
<Stack direction="row" spacing={1}>
<InfoIcon sx={{ color: "#f59e0b", fontSize: "1.2rem", mt: 0.1 }} />
<Box>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)", fontWeight: 500, mb: 0.5 }}>
Format Recommendation:
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", lineHeight: 1.6 }}>
<strong>16:9</strong> is recommended for most podcast videos as it matches standard video player dimensions and provides optimal viewing experience.
</Typography>
</Box>
</Stack>
</Paper>
</Box>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 2 }}>
<SecondaryButton onClick={onClose} disabled={isGenerating}>
Cancel
</SecondaryButton>
<PrimaryButton
onClick={handleRegenerate}
loading={isGenerating}
disabled={!prompt.trim() || isGenerating}
>
{isGenerating ? "Generating..." : "Regenerate Image"}
</PrimaryButton>
</DialogActions>
</Dialog>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress } from "@mui/material";
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress } from "@mui/material";
import {
EditNote as EditNoteIcon,
CheckCircle as CheckCircleIcon,
@@ -11,6 +11,8 @@ import {
import { Scene, Line, Knobs } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { LineEditor } from "./LineEditor";
import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerateModal";
import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal";
import { podcastApi } from "../../../services/podcastApi";
import { aiApiClient } from "../../../api/client";
@@ -24,6 +26,7 @@ interface SceneEditorProps {
onAudioGenerationStart?: (sceneId: string) => void;
onAudioGenerated?: (sceneId: string, audioUrl: string) => void;
idea?: string; // Podcast idea for image generation context
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
}
export const SceneEditor: React.FC<SceneEditorProps> = ({
@@ -36,10 +39,30 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
onAudioGenerationStart,
onAudioGenerated,
idea,
avatarUrl,
}) => {
const [localGenerating, setLocalGenerating] = useState(false);
const [generatingImage, setGeneratingImage] = useState(false);
const [imageGenerationStatus, setImageGenerationStatus] = useState<string>("");
const [imageGenerationProgress, setImageGenerationProgress] = useState<number>(0);
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
const [imageLoading, setImageLoading] = useState(false);
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
const [showAudioModal, setShowAudioModal] = useState(false);
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
voiceId: "Wise_Woman",
speed: 1.0,
volume: 1.0,
pitch: 0.0,
emotion: scene.emotion || "neutral",
englishNormalization: true,
sampleRate: 24000,
bitrate: 64000,
channel: "1",
format: "mp3",
languageBoost: "auto",
});
// Load audio as blob when audioUrl is available
useEffect(() => {
@@ -116,6 +139,99 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
};
}, [scene.audioUrl, scene.id]);
// Load image as blob when imageUrl is available
useEffect(() => {
if (!scene.imageUrl) {
// Clean up blob URL if imageUrl is removed
setImageBlobUrl((currentBlobUrl) => {
if (currentBlobUrl && currentBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(currentBlobUrl);
}
return null;
});
return;
}
let isMounted = true;
const currentImageUrl = scene.imageUrl; // Capture current value
const loadImageBlob = async () => {
try {
setImageLoading(true);
// Normalize path
let imagePath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`;
// Convert /api/story/images/ to /api/podcast/images/ if needed
if (imagePath.includes('/api/story/images/')) {
const filename = imagePath.split('/api/story/images/').pop() || '';
imagePath = `/api/podcast/images/${filename}`;
}
// Ensure it's a podcast image endpoint
if (!imagePath.includes('/api/podcast/images/')) {
const filename = imagePath.split('/').pop() || currentImageUrl;
imagePath = `/api/podcast/images/${filename}`;
}
// Remove query parameters if present
imagePath = imagePath.split('?')[0];
const response = await aiApiClient.get(imagePath, {
responseType: 'blob',
});
if (!isMounted) {
return;
}
// Double-check that imageUrl hasn't changed
if (scene.imageUrl !== currentImageUrl) {
return;
}
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
setImageBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== blobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return blobUrl;
});
} catch (error) {
console.error('[SceneEditor] Failed to load image blob:', error);
// Fallback: try with query token
try {
const token = localStorage.getItem('clerk_dashboard_token') || '';
if (token) {
const urlWithToken = `${currentImageUrl}?token=${encodeURIComponent(token)}`;
setImageBlobUrl(urlWithToken);
}
} catch (fallbackError) {
console.error('[SceneEditor] Fallback image loading failed:', fallbackError);
}
} finally {
if (isMounted) {
setImageLoading(false);
}
}
};
loadImageBlob();
return () => {
isMounted = false;
// Cleanup blob URL on unmount or when imageUrl changes
setImageBlobUrl((prevBlobUrl) => {
if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return null;
});
};
}, [scene.imageUrl]);
const updateLine = (updatedLine: Line) => {
const updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) };
onUpdateScene(updated);
@@ -126,7 +242,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
const hasAudio = Boolean(scene.audioUrl && audioBlobUrl);
const hasImage = Boolean(scene.imageUrl);
const handleApproveAndGenerate = async () => {
const handleApproveAndGenerate = async (settings?: AudioGenerationSettings) => {
const wasAlreadyApproved = scene.approved;
const sceneId = scene.id;
@@ -152,11 +268,20 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
const currentScene = { ...scene, approved: true };
// Generate audio
const effectiveSettings = settings || audioSettings;
const result = await podcastApi.renderSceneAudio({
scene: currentScene,
voiceId: "Wise_Woman",
emotion: scene.emotion || knobs.voice_emotion || "neutral",
speed: knobs.voice_speed || 1.0,
voiceId: effectiveSettings.voiceId || "Wise_Woman",
emotion: effectiveSettings.emotion || scene.emotion || knobs.voice_emotion || "neutral",
speed: effectiveSettings.speed ?? knobs.voice_speed ?? 1.0,
volume: effectiveSettings.volume ?? 1.0,
pitch: effectiveSettings.pitch ?? 0.0,
englishNormalization: effectiveSettings.englishNormalization ?? true,
sampleRate: effectiveSettings.sampleRate,
bitrate: effectiveSettings.bitrate,
channel: effectiveSettings.channel,
format: effectiveSettings.format,
languageBoost: effectiveSettings.languageBoost,
});
// Update scene with audio URL and ensure approved state
@@ -179,35 +304,138 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
}
};
const handleGenerateImage = async () => {
const handleGenerateImage = async (settings?: ImageGenerationSettings) => {
const sceneId = scene.id;
const startTime = Date.now();
let progressInterval: NodeJS.Timeout | null = null;
try {
setGeneratingImage(true);
setShowRegenerateModal(false);
setImageGenerationStatus("Submitting image generation request...");
setImageGenerationProgress(10);
// Build scene content from lines for context
const sceneContent = scene.lines.map((line) => line.text).join(" ");
// Log avatar URL for debugging
console.log("[SceneEditor] Generating image with avatarUrl:", avatarUrl);
console.log("[SceneEditor] Custom settings:", settings);
// Simulate progress updates during API call
progressInterval = setInterval(() => {
const elapsed = Date.now() - startTime;
const seconds = Math.floor(elapsed / 1000);
// Update status based on elapsed time
if (seconds < 5) {
setImageGenerationStatus("Submitting request to AI service...");
setImageGenerationProgress(15);
} else if (seconds < 15) {
setImageGenerationStatus("AI is generating your image...");
setImageGenerationProgress(30);
} else if (seconds < 30) {
setImageGenerationStatus("Creating character-consistent scene image...");
setImageGenerationProgress(50);
} else if (seconds < 60) {
setImageGenerationStatus("Rendering image details...");
setImageGenerationProgress(70);
} else {
setImageGenerationStatus(`Processing... (${seconds}s elapsed)`);
setImageGenerationProgress(Math.min(90, 50 + (seconds - 30) / 2));
}
}, 1000);
const result = await podcastApi.generateSceneImage({
sceneId: scene.id,
sceneTitle: scene.title,
sceneContent: sceneContent,
baseAvatarUrl: avatarUrl || undefined, // Pass base avatar URL for character consistency
idea: idea,
width: 1024,
height: 1024,
// Pass custom settings if provided
customPrompt: settings?.prompt,
style: settings?.style,
renderingSpeed: settings?.renderingSpeed,
aspectRatio: settings?.aspectRatio,
});
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
setImageGenerationStatus("Finalizing image...");
setImageGenerationProgress(95);
// Update scene with image URL
const updatedScene = { ...scene, imageUrl: result.image_url };
onUpdateScene(updatedScene);
} catch (error) {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setImageGenerationStatus(`Image generated successfully in ${elapsed}s`);
setImageGenerationProgress(100);
// Clear status after a moment
setTimeout(() => {
setImageGenerationStatus("");
setImageGenerationProgress(0);
}, 2000);
} catch (error: any) {
// Clear interval on error
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
console.error("Failed to generate image:", error);
// Extract error message from response if available
const errorMessage = error?.response?.data?.detail?.message
|| error?.response?.data?.detail?.error
|| error?.response?.data?.detail
|| error?.message
|| "Failed to generate image. Please try again.";
console.error("Error details:", {
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
message: errorMessage,
});
setImageGenerationStatus(`Error: ${errorMessage}`);
setImageGenerationProgress(0);
// Show user-friendly error message
alert(`Image generation failed: ${errorMessage}`);
throw error;
} finally {
// Ensure interval is cleared
if (progressInterval) {
clearInterval(progressInterval);
}
setGeneratingImage(false);
}
};
const handleRegenerateClick = () => {
setShowRegenerateModal(true);
};
const handleAudioRegenerateClick = () => {
if (hasAudio) {
setShowAudioModal(true);
} else {
handleApproveAndGenerate(audioSettings);
}
};
const handleAudioRegenerate = (settings: AudioGenerationSettings) => {
setAudioSettings(settings);
setShowAudioModal(false);
handleApproveAndGenerate(settings);
};
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2.5}>
@@ -256,7 +484,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
</Box>
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
<PrimaryButton
onClick={handleApproveAndGenerate}
onClick={handleAudioRegenerateClick}
disabled={approving || generating}
loading={approving || generating}
startIcon={
@@ -270,7 +498,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
}
tooltip={
hasAudio && !generating
? "Regenerate audio for this scene"
? "Regenerate audio for this scene with custom settings"
: generating
? "Generating audio..."
: scene.approved
@@ -290,7 +518,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
: "Approve & Generate Audio"}
</PrimaryButton>
<PrimaryButton
onClick={handleGenerateImage}
onClick={hasImage ? handleRegenerateClick : () => handleGenerateImage()}
disabled={generatingImage}
loading={generatingImage}
startIcon={
@@ -372,7 +600,157 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
</Box>
</>
)}
{/* Image Generation Progress - Show when generating */}
{generatingImage && (
<>
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1, mt: 1 }} />
<Box
sx={{
p: 2,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)",
borderRadius: 2,
border: "1px solid rgba(102, 126, 234, 0.2)",
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
<ImageIcon sx={{ color: "#667eea", fontSize: "1.25rem" }} />
<Typography variant="subtitle2" sx={{ color: "#667eea", fontWeight: 600 }}>
Generating Image...
</Typography>
</Stack>
{/* Progress Bar */}
<Box sx={{ mb: 1.5 }}>
<LinearProgress
variant="determinate"
value={imageGenerationProgress}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: alpha("#667eea", 0.1),
"& .MuiLinearProgress-bar": {
backgroundColor: "#667eea",
borderRadius: 4,
}
}}
/>
<Typography variant="caption" sx={{ color: "#667eea", mt: 0.5, display: "block", textAlign: "right" }}>
{imageGenerationProgress}%
</Typography>
</Box>
{/* Status Message */}
{imageGenerationStatus && (
<Typography variant="body2" sx={{ color: "#667eea", fontSize: "0.875rem", lineHeight: 1.6, mb: 1 }}>
{imageGenerationStatus}
</Typography>
)}
{/* Spinner */}
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", mt: 1 }}>
<CircularProgress size={32} sx={{ color: "#667eea" }} />
</Box>
</Box>
</>
)}
{/* Generated Image Display - Show when image exists and not generating */}
{scene.imageUrl && !generatingImage && (
<>
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1, mt: 1 }} />
<Box
sx={{
p: 2,
background: imageBlobUrl && !imageLoading
? "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)"
: "linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(217, 119, 6, 0.08) 100%)",
borderRadius: 2,
border: imageBlobUrl && !imageLoading
? "1px solid rgba(102, 126, 234, 0.2)"
: "1px solid rgba(245, 158, 11, 0.2)",
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
<ImageIcon sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontSize: "1.25rem" }} />
<Typography variant="subtitle2" sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontWeight: 600 }}>
{imageBlobUrl && !imageLoading ? "Image Generated" : "Loading Image..."}
</Typography>
</Stack>
{imageBlobUrl && !imageLoading ? (
<Box
sx={{
width: "100%",
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(102,126,234,0.2)",
background: alpha("#667eea", 0.05),
}}
>
<Box
component="img"
src={imageBlobUrl}
alt={scene.title}
sx={{
width: "100%",
height: "auto",
display: "block",
maxHeight: 400,
objectFit: "cover",
}}
onError={(e) => {
console.error('[SceneEditor] Image failed to load:', {
src: e.currentTarget.src,
imageUrl: scene.imageUrl,
imageBlobUrl,
});
}}
onLoad={() => {
console.log('[SceneEditor] Image loaded successfully');
}}
/>
</Box>
) : (
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", py: 2 }}>
<CircularProgress size={24} sx={{ color: "#d97706" }} />
</Box>
)}
</Box>
</>
)}
</Stack>
{/* Image Regeneration Modal */}
<ImageRegenerateModal
open={showRegenerateModal}
onClose={() => setShowRegenerateModal(false)}
onRegenerate={handleGenerateImage}
initialPrompt={(() => {
const promptParts = [
`Scene: ${scene.title}`,
"Professional podcast recording studio",
"Modern microphone setup",
"Clean background, professional lighting",
"16:9 aspect ratio, video-optimized composition"
];
if (idea) {
promptParts.push(`Topic: ${idea.substring(0, 60)}`);
}
return promptParts.join(", ");
})()}
initialStyle="Realistic"
initialRenderingSpeed="Quality"
initialAspectRatio="16:9"
isGenerating={generatingImage}
/>
<AudioRegenerateModal
open={showAudioModal}
onClose={() => setShowAudioModal(false)}
onRegenerate={handleAudioRegenerate}
initialSettings={audioSettings}
isGenerating={generating}
/>
</GlassyCard>
);
};

View File

@@ -22,6 +22,7 @@ interface ScriptEditorProps {
onBackToResearch: () => void;
onProceedToRendering: (script: Script) => void;
onError: (message: string) => void;
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
}
export const ScriptEditor: React.FC<ScriptEditorProps> = ({
@@ -37,6 +38,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
onBackToResearch,
onProceedToRendering,
onError,
avatarUrl,
}) => {
const [script, setScript] = useState<Script | null>(initialScript);
const [loading, setLoading] = useState(false);
@@ -52,6 +54,12 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
sceneCount: number;
} | null>(null);
// Defer upward script updates to avoid setState during render warnings
const emitScriptChange = useCallback(
(next: Script) => Promise.resolve().then(() => onScriptChange(next)),
[onScriptChange]
);
// Sync with parent state
useEffect(() => {
if (initialScript) {
@@ -85,7 +93,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
.then((res) => {
if (mounted) {
setScript(res);
onScriptChange(res);
emitScriptChange(res);
setError(null);
}
})
@@ -108,7 +116,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
...currentScript,
scenes: currentScript.scenes.map((s) => (s.id === updated.id ? { ...s, ...updated } : s))
};
onScriptChange(updatedScript);
emitScriptChange(updatedScript);
return updatedScript;
});
};
@@ -124,7 +132,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
...currentScript,
scenes: currentScript.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
};
onScriptChange(updatedScript);
emitScriptChange(updatedScript);
return updatedScript;
});
} catch (err) {
@@ -570,11 +578,12 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
s.id === sceneId ? { ...s, audioUrl, approved: true } : s
);
const updatedScript = { ...currentScript, scenes: updatedScenes };
onScriptChange(updatedScript);
emitScriptChange(updatedScript);
return updatedScript;
});
}}
idea={idea}
avatarUrl={avatarUrl}
/>
</GlassyCard>
))}