AI podcast project
This commit is contained in:
@@ -1,16 +1,97 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, Paper, alpha } from "@mui/material";
|
||||
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon } from "@mui/icons-material";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress } from "@mui/material";
|
||||
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "./types";
|
||||
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
|
||||
import { Refresh as RefreshIcon } from "@mui/icons-material";
|
||||
import { aiApiClient } from "../../api/client";
|
||||
|
||||
interface AnalysisPanelProps {
|
||||
analysis: PodcastAnalysis | null;
|
||||
idea?: string;
|
||||
duration?: number;
|
||||
speakers?: number;
|
||||
avatarUrl?: string | null;
|
||||
avatarPrompt?: string | null;
|
||||
onRegenerate?: () => void;
|
||||
}
|
||||
|
||||
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, onRegenerate }) => {
|
||||
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, duration, speakers, avatarUrl, avatarPrompt, onRegenerate }) => {
|
||||
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
|
||||
// Load avatar image as blob for authenticated URLs
|
||||
useEffect(() => {
|
||||
if (!avatarUrl) {
|
||||
setAvatarBlobUrl(null);
|
||||
setAvatarError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's already a blob URL
|
||||
if (avatarUrl.startsWith('blob:')) {
|
||||
setAvatarBlobUrl(avatarUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's an authenticated endpoint
|
||||
const isAuthenticatedEndpoint = avatarUrl.includes('/api/podcast/images/') || avatarUrl.includes('/api/podcast/avatar/');
|
||||
|
||||
let currentBlobUrl: string | null = null;
|
||||
|
||||
if (isAuthenticatedEndpoint) {
|
||||
setAvatarLoading(true);
|
||||
setAvatarError(false);
|
||||
|
||||
const loadAvatarBlob = async () => {
|
||||
try {
|
||||
const response = await aiApiClient.get(avatarUrl, { responseType: 'blob' });
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
currentBlobUrl = blobUrl;
|
||||
setAvatarBlobUrl(blobUrl);
|
||||
setAvatarError(false);
|
||||
} catch (error) {
|
||||
console.error('[AnalysisPanel] Failed to load avatar as blob:', error);
|
||||
// Fallback: try with query token
|
||||
try {
|
||||
const token = localStorage.getItem('clerk_dashboard_token') || '';
|
||||
if (token) {
|
||||
const urlWithToken = `${avatarUrl}?token=${encodeURIComponent(token)}`;
|
||||
setAvatarBlobUrl(urlWithToken);
|
||||
} else {
|
||||
setAvatarError(true);
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error('[AnalysisPanel] Fallback avatar loading failed:', fallbackError);
|
||||
setAvatarError(true);
|
||||
}
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAvatarBlob();
|
||||
|
||||
// Cleanup blob URL on unmount or when avatarUrl changes
|
||||
return () => {
|
||||
if (currentBlobUrl && currentBlobUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(currentBlobUrl);
|
||||
}
|
||||
// Also cleanup any previous blob URL from state
|
||||
setAvatarBlobUrl((prev) => {
|
||||
if (prev && prev.startsWith('blob:') && prev !== currentBlobUrl) {
|
||||
URL.revokeObjectURL(prev);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
} else {
|
||||
// Direct URL, use as-is
|
||||
setAvatarBlobUrl(avatarUrl);
|
||||
}
|
||||
}, [avatarUrl]);
|
||||
|
||||
if (!analysis) return null;
|
||||
return (
|
||||
<GlassyCard
|
||||
@@ -54,6 +135,229 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, onRegene
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
|
||||
|
||||
{/* Inputs Section */}
|
||||
{(idea || duration || speakers || avatarUrl || avatarPrompt) && (
|
||||
<>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
fontWeight: 700,
|
||||
mb: 1.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
Your Inputs
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: avatarUrl ? "1fr 1fr" : "1fr" },
|
||||
gap: 3,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{/* Left Column: Text Inputs */}
|
||||
<Stack spacing={1.5}>
|
||||
{idea && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Podcast Idea
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", wordBreak: "break-word" }}>
|
||||
{idea}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||
{duration !== undefined && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Duration
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${duration} minutes`}
|
||||
size="small"
|
||||
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{speakers !== undefined && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Speakers
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${speakers} ${speakers === 1 ? "speaker" : "speakers"}`}
|
||||
size="small"
|
||||
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* AI Prompt Used for Avatar Generation */}
|
||||
{avatarUrl && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
mb: 0.75,
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ fontSize: 14 }} />
|
||||
AI Generation Prompt
|
||||
</Typography>
|
||||
{avatarPrompt ? (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1.5,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 1.5,
|
||||
maxHeight: 200,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#475569",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{avatarPrompt}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1.5,
|
||||
background: "#f1f5f9",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontStyle: "italic",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
Prompt not available (avatar was uploaded or generated before this feature was added)
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Right Column: Presenter Avatar */}
|
||||
{avatarUrl && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<PersonIcon sx={{ fontSize: 16 }} />
|
||||
Presenter Avatar
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: { xs: "100%", md: 300 },
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(102,126,234,0.2)",
|
||||
background: alpha("#667eea", 0.05),
|
||||
position: "relative",
|
||||
aspectRatio: "1",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
>
|
||||
{avatarLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
) : avatarError ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
background: "#fef2f2",
|
||||
color: "#dc2626",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ textAlign: "center" }}>
|
||||
Failed to load avatar
|
||||
</Typography>
|
||||
</Box>
|
||||
) : avatarBlobUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={avatarBlobUrl}
|
||||
alt="Podcast Presenter"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error('[AnalysisPanel] Avatar image failed to load:', {
|
||||
src: e.currentTarget.src,
|
||||
avatarUrl,
|
||||
avatarBlobUrl,
|
||||
});
|
||||
setAvatarError(true);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('[AnalysisPanel] Avatar image loaded successfully');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
Info as InfoIcon,
|
||||
HelpOutline as HelpOutlineIcon,
|
||||
AttachMoney as AttachMoneyIcon,
|
||||
CloudUpload as CloudUploadIcon,
|
||||
Person as PersonIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { CreateProjectPayload, Knobs } from "./types";
|
||||
import { PrimaryButton, SecondaryButton } from "./ui";
|
||||
@@ -35,6 +38,9 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
const [budgetCap, setBudgetCap] = useState<number>(50);
|
||||
const [voiceFile, setVoiceFile] = useState<File | null>(null);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null); // Store uploaded avatar URL
|
||||
const [makingPresentable, setMakingPresentable] = useState(false);
|
||||
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
|
||||
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
||||
|
||||
@@ -107,8 +113,22 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
|
||||
const canSubmit = Boolean(idea || url);
|
||||
|
||||
const submit = () => {
|
||||
const submit = async () => {
|
||||
if (!canSubmit || isSubmitting) return;
|
||||
|
||||
// If avatar was uploaded but not yet uploaded to server, upload it now
|
||||
let finalAvatarUrl: string | null = avatarUrl;
|
||||
if (avatarFile && !avatarUrl) {
|
||||
try {
|
||||
const { podcastApi } = await import("../../services/podcastApi");
|
||||
const uploadResult = await podcastApi.uploadAvatar(avatarFile);
|
||||
finalAvatarUrl = uploadResult.avatar_url;
|
||||
} catch (error) {
|
||||
console.error('Avatar upload failed:', error);
|
||||
// Continue without avatar
|
||||
}
|
||||
}
|
||||
|
||||
onCreate({
|
||||
ideaOrUrl: idea || url,
|
||||
speakers,
|
||||
@@ -116,6 +136,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
knobs,
|
||||
budgetCap,
|
||||
files: { voiceFile, avatarFile },
|
||||
avatarUrl: finalAvatarUrl,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -127,6 +148,9 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
setBudgetCap(50);
|
||||
setVoiceFile(null);
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview(null);
|
||||
setAvatarUrl(null);
|
||||
setMakingPresentable(false);
|
||||
setKnobs({ ...defaultKnobs });
|
||||
setPlaceholderIndex(0);
|
||||
};
|
||||
@@ -141,6 +165,68 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
setSpeakers(clamped);
|
||||
};
|
||||
|
||||
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
console.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
// Validate file size (e.g., max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
console.error('Image file size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
setAvatarFile(file);
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload image immediately to get URL (for "Make Presentable" feature)
|
||||
try {
|
||||
const { podcastApi } = await import("../../services/podcastApi");
|
||||
const uploadResult = await podcastApi.uploadAvatar(file);
|
||||
setAvatarUrl(uploadResult.avatar_url);
|
||||
} catch (error) {
|
||||
console.error('Avatar upload failed:', error);
|
||||
// Continue with local preview - upload will happen on submit
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = () => {
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview(null);
|
||||
setAvatarUrl(null);
|
||||
setMakingPresentable(false);
|
||||
};
|
||||
|
||||
const handleMakePresentable = async () => {
|
||||
if (!avatarUrl || makingPresentable) return;
|
||||
|
||||
try {
|
||||
setMakingPresentable(true);
|
||||
const { podcastApi } = await import("../../services/podcastApi");
|
||||
const result = await podcastApi.makeAvatarPresentable(avatarUrl);
|
||||
|
||||
// Fetch the transformed image as blob to display
|
||||
const { aiApiClient } = await import("../../api/client");
|
||||
const response = await aiApiClient.get(result.avatar_url, { responseType: 'blob' });
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
setAvatarPreview(blobUrl);
|
||||
setAvatarUrl(result.avatar_url);
|
||||
} catch (error) {
|
||||
console.error('Failed to make avatar presentable:', error);
|
||||
// Could show error message to user
|
||||
} finally {
|
||||
setMakingPresentable(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
@@ -601,181 +687,372 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
{/* Settings Section */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
background: alpha("#f8fafc", 0.5),
|
||||
border: "1px solid rgba(15, 23, 42, 0.06)",
|
||||
p: 3.5,
|
||||
borderRadius: 2.5,
|
||||
background: "linear-gradient(135deg, rgba(248, 250, 252, 0.8) 0%, rgba(241, 245, 249, 0.8) 100%)",
|
||||
border: "1.5px solid rgba(15, 23, 42, 0.08)",
|
||||
boxShadow: "0 1px 3px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.06)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
|
||||
Podcast Settings
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="flex-start">
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
label="Duration (minutes)"
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => handleDurationChange(Number(e.target.value) || 1)}
|
||||
InputProps={{ inputProps: { min: 1, max: 10 } }}
|
||||
size="small"
|
||||
helperText={duration > 10 ? "Maximum duration is 10 minutes" : `Recommended: 1-3 minutes for quick tests (currently: ${duration} min)`}
|
||||
error={duration > 10}
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: 220,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1.5px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#64748b",
|
||||
"&.Mui-focused": {
|
||||
color: "#667eea",
|
||||
},
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: "#64748b",
|
||||
fontSize: "0.8125rem",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Number of speakers"
|
||||
type="number"
|
||||
value={speakers}
|
||||
onChange={(e) => handleSpeakersChange(Number(e.target.value) || 1)}
|
||||
InputProps={{ inputProps: { min: 1, max: 2 } }}
|
||||
size="small"
|
||||
helperText={speakers > 2 ? "Maximum 2 speakers supported" : `Supports 1-2 speakers (currently: ${speakers})`}
|
||||
error={speakers > 2}
|
||||
sx={{
|
||||
maxWidth: 220,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1.5px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#64748b",
|
||||
"&.Mui-focused": {
|
||||
color: "#667eea",
|
||||
},
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: "#64748b",
|
||||
fontSize: "0.8125rem",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Cost Breakdown Panel - positioned in empty space */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.08) 100%)",
|
||||
border: "1.5px solid rgba(16, 185, 129, 0.2)",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
minWidth: { xs: "100%", sm: 300 },
|
||||
flex: { xs: "none", sm: "0 0 auto" },
|
||||
boxShadow: "0 2px 8px rgba(16, 185, 129, 0.08)",
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1.5,
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<AttachMoneyIcon sx={{ fontSize: "1.125rem", color: "#059669" }} />
|
||||
</Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.875rem" }}>
|
||||
Estimated Cost
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
color: "#059669",
|
||||
fontWeight: 700,
|
||||
fontSize: "1.75rem",
|
||||
lineHeight: 1.2,
|
||||
<AutoAwesomeIcon sx={{ color: "#667eea", fontSize: "1.25rem" }} />
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 700, fontSize: "1.125rem", letterSpacing: "-0.01em" }}>
|
||||
Podcast Settings
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", lg: "row" }} spacing={3} alignItems="flex-start">
|
||||
{/* Duration and Speakers in vertical column */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: { xs: "1 1 auto", lg: "0 0 280px" },
|
||||
width: { xs: "100%", lg: "280px" },
|
||||
p: 2.5,
|
||||
borderRadius: 2,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(15, 23, 42, 0.08)",
|
||||
boxShadow: "0 1px 2px rgba(15, 23, 42, 0.04)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, color: "#0f172a", fontWeight: 600, fontSize: "0.875rem" }}>
|
||||
Basic Configuration
|
||||
</Typography>
|
||||
<Stack spacing={2.5}>
|
||||
<TextField
|
||||
label="Duration (minutes)"
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => handleDurationChange(Number(e.target.value) || 1)}
|
||||
InputProps={{ inputProps: { min: 1, max: 10 } }}
|
||||
size="small"
|
||||
helperText={duration > 10 ? "Maximum duration is 10 minutes" : `Recommended: 1-3 minutes for quick tests`}
|
||||
error={duration > 10}
|
||||
fullWidth
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
border: "1.5px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
transition: "all 0.2s",
|
||||
"&:hover": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.08)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 2,
|
||||
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.12)",
|
||||
},
|
||||
},
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#64748b",
|
||||
fontWeight: 500,
|
||||
"&.Mui-focused": {
|
||||
color: "#667eea",
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: duration > 10 ? "#dc2626" : "#64748b",
|
||||
fontSize: "0.8125rem",
|
||||
mt: 0.75,
|
||||
},
|
||||
}}
|
||||
>
|
||||
${estimatedCost.total}
|
||||
</Typography>
|
||||
<Stack spacing={0.75} sx={{ mt: 0.5 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
|
||||
Audio Generation
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
|
||||
${estimatedCost.ttsCost}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
|
||||
Avatar Creation
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
|
||||
${estimatedCost.avatarCost}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
|
||||
Video Rendering
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
|
||||
${estimatedCost.videoCost}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
|
||||
Research
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
|
||||
${estimatedCost.researchCost}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
/>
|
||||
<TextField
|
||||
label="Number of speakers"
|
||||
type="number"
|
||||
value={speakers}
|
||||
onChange={(e) => handleSpeakersChange(Number(e.target.value) || 1)}
|
||||
InputProps={{ inputProps: { min: 1, max: 2 } }}
|
||||
size="small"
|
||||
helperText={speakers > 2 ? "Maximum 2 speakers supported" : `Supports 1-2 speakers`}
|
||||
error={speakers > 2}
|
||||
fullWidth
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
border: "1.5px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
transition: "all 0.2s",
|
||||
"&:hover": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.08)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 2,
|
||||
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.12)",
|
||||
},
|
||||
},
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#64748b",
|
||||
fontWeight: 500,
|
||||
"&.Mui-focused": {
|
||||
color: "#667eea",
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: speakers > 2 ? "#dc2626" : "#64748b",
|
||||
fontSize: "0.8125rem",
|
||||
mt: 0.75,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Avatar Upload Section - replacing Estimated Cost */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
p: 2.5,
|
||||
borderRadius: 2,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(15, 23, 42, 0.08)",
|
||||
boxShadow: "0 1px 2px rgba(15, 23, 42, 0.04)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
pt: 1.5,
|
||||
borderTop: "1.5px solid rgba(16, 185, 129, 0.15)",
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 1.5,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.75rem", fontWeight: 500 }}>
|
||||
{duration} min • {speakers} speaker{speakers > 1 ? "s" : ""} • {knobs.bitrate === "hd" ? "HD" : "Standard"} quality
|
||||
</Typography>
|
||||
<PersonIcon fontSize="small" sx={{ color: "#667eea" }} />
|
||||
</Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
|
||||
Podcast Presenter Avatar
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Avatar Options:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
|
||||
<strong>Upload your photo:</strong> We'll enhance it into a professional podcast presenter using AI. Click "Make Presentable" after upload.<br/><br/>
|
||||
<strong>Skip upload:</strong> After analysis completes, we'll generate professional presenter images based on your podcast topic, audience, and speaker count.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
bgcolor: "#0f172a",
|
||||
color: "#ffffff",
|
||||
maxWidth: 320,
|
||||
fontSize: "0.875rem",
|
||||
p: 1.5,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
},
|
||||
},
|
||||
arrow: {
|
||||
sx: {
|
||||
color: "#0f172a",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<InfoIcon fontSize="small" sx={{ color: "#94a3b8", cursor: "help" }} />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2.5} alignItems="flex-start">
|
||||
{avatarPreview ? (
|
||||
<Stack spacing={1.5} sx={{ flexShrink: 0 }}>
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
sx={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
objectFit: "cover",
|
||||
borderRadius: 2.5,
|
||||
border: "2px solid #e2e8f0",
|
||||
boxShadow: "0 2px 8px rgba(15, 23, 42, 0.08)",
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemoveAvatar}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
bgcolor: "white",
|
||||
border: "1.5px solid #e2e8f0",
|
||||
boxShadow: "0 2px 4px rgba(15, 23, 42, 0.1)",
|
||||
"&:hover": {
|
||||
bgcolor: "#f8fafc",
|
||||
borderColor: "#dc2626",
|
||||
color: "#dc2626",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{avatarUrl && (
|
||||
<Tooltip
|
||||
title="Transform your uploaded photo into a professional podcast presenter. This AI enhancement optimizes your photo for video generation while maintaining your appearance and identity."
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box>
|
||||
<SecondaryButton
|
||||
onClick={handleMakePresentable}
|
||||
disabled={makingPresentable}
|
||||
loading={makingPresentable}
|
||||
startIcon={!makingPresentable ? <AutoAwesomeIcon fontSize="small" /> : undefined}
|
||||
sx={{
|
||||
fontSize: "0.8125rem",
|
||||
py: 0.75,
|
||||
width: "100%",
|
||||
background: makingPresentable ? undefined : "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)",
|
||||
border: makingPresentable ? undefined : "1px solid rgba(102, 126, 234, 0.2)",
|
||||
color: makingPresentable ? undefined : "#667eea",
|
||||
fontWeight: 600,
|
||||
"&:hover": {
|
||||
background: makingPresentable ? undefined : "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{makingPresentable ? "Transforming..." : "Make Presentable"}
|
||||
</SecondaryButton>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box
|
||||
component="label"
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: { xs: "100%", sm: 200 },
|
||||
minHeight: 140,
|
||||
border: "2px dashed #cbd5e1",
|
||||
borderRadius: 2.5,
|
||||
bgcolor: "#f8fafc",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
flexShrink: 0,
|
||||
"&:hover": {
|
||||
borderColor: "#667eea",
|
||||
bgcolor: "#f1f5f9",
|
||||
borderWidth: "2.5px",
|
||||
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.08)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<CloudUploadIcon sx={{ color: "#94a3b8", fontSize: 36, mb: 1.5 }} />
|
||||
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 600, mb: 0.5 }}>
|
||||
Upload Your Photo
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#94a3b8", textAlign: "center", px: 2, lineHeight: 1.5 }}>
|
||||
Optional - We'll enhance it with AI or generate one after analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack spacing={1.5}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.9375rem", lineHeight: 1.7, fontWeight: 500, mb: 1 }}>
|
||||
Choose Your Avatar Option:
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
background: alpha("#f0f4ff", 0.6),
|
||||
border: "1px solid rgba(99, 102, 241, 0.2)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.875rem", fontWeight: 600, mb: 0.5, display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea" }} />
|
||||
Upload Your Photo (Recommended)
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", fontSize: "0.8125rem", lineHeight: 1.6 }}>
|
||||
Upload your photo and we'll enhance it into a professional podcast presenter using AI. After upload, click <strong>"Make Presentable"</strong> to transform your photo into a podcast-ready avatar that maintains your appearance while optimizing it for video generation.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
background: alpha("#f8fafc", 0.8),
|
||||
border: "1px solid rgba(15, 23, 42, 0.1)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.875rem", fontWeight: 600, mb: 0.5, display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<PersonIcon fontSize="small" sx={{ color: "#64748b" }} />
|
||||
Let ALwrity Generate (Alternative)
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", fontSize: "0.8125rem", lineHeight: 1.6 }}>
|
||||
If you skip upload, we'll automatically generate professional presenter images <strong>after the AI analysis completes</strong>. The generated presenters will be tailored to your podcast topic, target audience, content type, and speaker count for the best fit.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
background: alpha("#f0f4ff", 0.5),
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "#6366f1", fontSize: "0.8125rem", fontWeight: 500, display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<InfoIcon fontSize="inherit" />
|
||||
Supported formats: JPG, PNG, WebP (max 5MB)
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Stack, Typography, Divider, Chip, Tooltip, IconButton, alpha } from "@mui/material";
|
||||
import { OpenInNew as OpenInNewIcon, ContentCopy as ContentCopyIcon } from "@mui/icons-material";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Stack, Typography, Divider, Chip, Tooltip, IconButton, alpha, Box } from "@mui/material";
|
||||
import { OpenInNew as OpenInNewIcon, ContentCopy as ContentCopyIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon } from "@mui/icons-material";
|
||||
import { Fact } from "./types";
|
||||
import { GlassyCard, glassyCardSx } from "./ui";
|
||||
|
||||
@@ -8,7 +8,10 @@ interface FactCardProps {
|
||||
fact: Fact;
|
||||
}
|
||||
|
||||
const MAX_PREVIEW_LENGTH = 200; // Characters to show before truncation
|
||||
|
||||
export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
return new URL(fact.url).hostname;
|
||||
@@ -21,30 +24,77 @@ export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
|
||||
navigator.clipboard.writeText(fact.quote);
|
||||
};
|
||||
|
||||
const shouldTruncate = fact.quote.length > MAX_PREVIEW_LENGTH;
|
||||
const previewText = shouldTruncate ? fact.quote.slice(0, MAX_PREVIEW_LENGTH).trim() + "..." : fact.quote;
|
||||
const fullText = fact.quote;
|
||||
|
||||
return (
|
||||
<GlassyCard
|
||||
whileHover={{ y: -4 }}
|
||||
whileHover={{ y: -2 }}
|
||||
sx={{
|
||||
...glassyCardSx,
|
||||
p: 2,
|
||||
p: 1.5,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102,126,234,0.25)",
|
||||
boxShadow: "0 12px 28px rgba(15,23,42,0.08)",
|
||||
boxShadow: "0 8px 20px rgba(15,23,42,0.08)",
|
||||
},
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.6, color: "#0f172a" }}>
|
||||
{fact.quote}
|
||||
</Typography>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
|
||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" spacing={1} alignItems="center" flex={1}>
|
||||
<OpenInNewIcon fontSize="small" sx={{ color: "rgba(15,23,42,0.6)" }} />
|
||||
<Stack spacing={1} sx={{ flex: 1, minHeight: 0 }}>
|
||||
{/* Quote Text - Truncated with expand option */}
|
||||
<Box sx={{ flex: 1, minHeight: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
lineHeight: 1.5,
|
||||
color: "#0f172a",
|
||||
fontSize: "0.8125rem",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: expanded ? "none" : 4,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
mb: shouldTruncate ? 0.5 : 0,
|
||||
}}
|
||||
>
|
||||
{expanded ? fullText : previewText}
|
||||
</Typography>
|
||||
{shouldTruncate && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
sx={{
|
||||
p: 0.25,
|
||||
mt: 0.25,
|
||||
color: "#4f46e5",
|
||||
"&:hover": { background: alpha("#4f46e5", 0.1) },
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<ExpandLessIcon fontSize="small" />
|
||||
) : (
|
||||
<ExpandMoreIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)", my: 0.5 }} />
|
||||
|
||||
{/* Source and Actions */}
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" flex={1} minWidth={0}>
|
||||
<OpenInNewIcon fontSize="small" sx={{ color: "rgba(15,23,42,0.5)", flexShrink: 0 }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="a"
|
||||
@@ -55,34 +105,49 @@ export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
|
||||
color: "#4f46e5",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "0.7rem",
|
||||
}}
|
||||
>
|
||||
{hostname || "source"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Tooltip title="Copy citation">
|
||||
<IconButton size="small" onClick={handleCopy} sx={{ color: "rgba(15,23,42,0.65)" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy();
|
||||
}}
|
||||
sx={{
|
||||
color: "rgba(15,23,42,0.6)",
|
||||
p: 0.5,
|
||||
"&:hover": { background: alpha("#4f46e5", 0.1) },
|
||||
}}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2}>
|
||||
|
||||
{/* Confidence and Date */}
|
||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
|
||||
<Chip
|
||||
label={`${(fact.confidence * 100).toFixed(0)}% confidence`}
|
||||
label={`${(fact.confidence * 100).toFixed(0)}%`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
height: 18,
|
||||
fontSize: "0.65rem",
|
||||
background: alpha("#22c55e", 0.15),
|
||||
color: "#15803d",
|
||||
border: "1px solid rgba(34,197,94,0.35)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "#475569" }}>
|
||||
{fact.date}
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
|
||||
{fact.date !== "Unknown" ? new Date(fact.date).toLocaleDateString("en-US", { month: "short", year: "numeric" }) : fact.date}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -217,6 +217,11 @@ const PodcastDashboard: React.FC = () => {
|
||||
{analysis && !showScriptEditor && !showRenderQueue && (
|
||||
<AnalysisPanel
|
||||
analysis={analysis}
|
||||
idea={project?.idea}
|
||||
duration={project?.duration}
|
||||
speakers={project?.speakers}
|
||||
avatarUrl={project?.avatarUrl}
|
||||
avatarPrompt={project?.avatarPrompt}
|
||||
onRegenerate={() => {}}
|
||||
/>
|
||||
)}
|
||||
@@ -259,6 +264,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
onBackToResearch={() => setShowScriptEditor(false)}
|
||||
onProceedToRendering={(s) => workflow.handleProceedToRendering(s)}
|
||||
onError={(msg) => workflow.setAnnouncement(msg)}
|
||||
avatarUrl={project?.avatarUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,8 +19,15 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode })
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
|
||||
<Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-start"
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
sx={{ width: "100%", minWidth: 0 }} // Ensure full width and allow wrapping
|
||||
>
|
||||
<Box sx={{ minWidth: 0, flex: { xs: "1 1 100%", md: "0 1 auto" } }}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
@@ -30,24 +37,61 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode })
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
fontSize: { xs: "1.5rem", md: "2rem" },
|
||||
}}
|
||||
>
|
||||
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
|
||||
AI Podcast Maker
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ display: { xs: "none", sm: "block" } }}>
|
||||
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
useFlexGap
|
||||
sx={{
|
||||
justifyContent: { xs: "flex-start", md: "flex-end" },
|
||||
gap: { xs: 0.5, md: 1 },
|
||||
minWidth: 0,
|
||||
width: { xs: "100%", md: "auto" }, // Full width on mobile to allow wrapping
|
||||
flex: { xs: "1 1 100%", md: "0 1 auto" }, // Take full width on mobile
|
||||
}}
|
||||
>
|
||||
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
|
||||
<SecondaryButton onClick={() => window.open("/docs", "_blank")} startIcon={<InfoIcon />}>
|
||||
<SecondaryButton
|
||||
onClick={() => window.open("/docs", "_blank")}
|
||||
startIcon={<InfoIcon />}
|
||||
sx={{
|
||||
display: { xs: "none", lg: "flex" },
|
||||
// Override for light theme
|
||||
borderColor: "rgba(102, 126, 234, 0.3) !important",
|
||||
color: "#667eea !important",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102, 126, 234, 0.5) !important",
|
||||
background: "rgba(102, 126, 234, 0.1) !important",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Help
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
|
||||
startIcon={<LibraryMusicIcon />}
|
||||
tooltip="View all podcast episodes in Asset Library"
|
||||
sx={{
|
||||
display: { xs: "none", xl: "flex" },
|
||||
// Override for light theme
|
||||
borderColor: "rgba(102, 126, 234, 0.3) !important",
|
||||
color: "#667eea !important",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102, 126, 234, 0.5) !important",
|
||||
background: "rgba(102, 126, 234, 0.1) !important",
|
||||
},
|
||||
}}
|
||||
>
|
||||
My Episodes
|
||||
</SecondaryButton>
|
||||
@@ -55,10 +99,30 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode })
|
||||
onClick={onShowProjects}
|
||||
startIcon={<MicIcon />}
|
||||
tooltip="View and resume saved projects"
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
display: "flex !important", // Always show "My Projects" - force display
|
||||
order: { xs: 1, md: 0 }, // Show first on mobile
|
||||
// Override button colors for light theme
|
||||
borderColor: "rgba(102, 126, 234, 0.3) !important",
|
||||
color: "#667eea !important",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102, 126, 234, 0.5) !important",
|
||||
background: "rgba(102, 126, 234, 0.1) !important",
|
||||
},
|
||||
}}
|
||||
>
|
||||
My Projects
|
||||
</SecondaryButton>
|
||||
<PrimaryButton onClick={onNewEpisode} startIcon={<AutoAwesomeIcon />}>
|
||||
<PrimaryButton
|
||||
onClick={onNewEpisode}
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
display: "flex", // Always show "New Episode"
|
||||
order: { xs: 0, md: 1 }, // Show first on mobile
|
||||
}}
|
||||
>
|
||||
New Episode
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
import { Stack, Typography, Chip, Divider, Box, alpha } from "@mui/material";
|
||||
import React, { useMemo } from "react";
|
||||
import { Stack, Typography, Chip, Divider, Box, alpha, Paper } from "@mui/material";
|
||||
import {
|
||||
Insights as InsightsIcon,
|
||||
Search as SearchIcon,
|
||||
AttachMoney as AttachMoneyIcon,
|
||||
EditNote as EditNoteIcon,
|
||||
Article as ArticleIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Research } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
@@ -21,17 +22,68 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
canGenerateScript,
|
||||
onGenerateScript,
|
||||
}) => {
|
||||
// Extract key insights from summary if it's long
|
||||
const summaryParts = useMemo(() => {
|
||||
const fullSummary = research.summary || "";
|
||||
if (fullSummary.length > 500) {
|
||||
// Try to split into paragraphs or sentences
|
||||
const sentences = fullSummary.split(/[.!?]\s+/).filter(s => s.trim().length > 20);
|
||||
const keyPoints = sentences.slice(0, 3);
|
||||
const remainingText = sentences.slice(3).join(". ") + (sentences.length > 3 ? "." : "");
|
||||
return { keyPoints, remainingText };
|
||||
}
|
||||
return { keyPoints: [], remainingText: fullSummary };
|
||||
}, [research.summary]);
|
||||
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
|
||||
<Box sx={{ flex: 1, minWidth: { xs: "100%", md: "60%" } }}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1.5 }}>
|
||||
<InsightsIcon />
|
||||
Research Summary
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, lineHeight: 1.7 }}>
|
||||
{research.summary}
|
||||
|
||||
{/* Key Insights */}
|
||||
{summaryParts.keyPoints.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 600, display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<ArticleIcon fontSize="small" />
|
||||
Key Insights
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{summaryParts.keyPoints.map((point, idx) => (
|
||||
<Paper
|
||||
key={idx}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
background: alpha("#667eea", 0.05),
|
||||
border: "1px solid rgba(102, 126, 234, 0.15)",
|
||||
borderRadius: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.6, fontSize: "0.875rem" }}>
|
||||
{point}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Full Summary Text */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mb: 2,
|
||||
lineHeight: 1.7,
|
||||
fontSize: "0.875rem",
|
||||
color: "#475569",
|
||||
}}
|
||||
>
|
||||
{summaryParts.remainingText || research.summary}
|
||||
</Typography>
|
||||
|
||||
{/* Research Metadata */}
|
||||
@@ -126,15 +178,23 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
{research.factCards.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5, flexWrap: "wrap", gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600 }}>
|
||||
Research Sources & Facts ({research.factCards.length})
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#64748b" }}>
|
||||
Click any card to view source details
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.75rem" }}>
|
||||
Click to expand • Hover to see source
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", lg: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", md: "repeat(3, 1fr)", lg: "repeat(4, 1fr)" },
|
||||
gap: 1.5,
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{research.factCards.map((fact) => (
|
||||
<FactCard key={fact.id} fact={fact} />
|
||||
))}
|
||||
|
||||
@@ -94,17 +94,66 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setShowRenderQueue(false);
|
||||
try {
|
||||
setIsAnalyzing(true);
|
||||
|
||||
// Upload avatar if provided, or generate presenters
|
||||
let avatarUrl: string | null = null;
|
||||
if (payload.files.avatarFile) {
|
||||
try {
|
||||
setAnnouncement("Uploading presenter avatar...");
|
||||
const uploadResponse = await podcastApi.uploadAvatar(payload.files.avatarFile);
|
||||
avatarUrl = uploadResponse.avatar_url;
|
||||
} catch (error) {
|
||||
console.error('Avatar upload failed:', error);
|
||||
// Continue without avatar - will generate one later
|
||||
}
|
||||
}
|
||||
|
||||
setAnnouncement("Analyzing your idea — AI suggestions incoming");
|
||||
const result = await podcastApi.createProject(payload);
|
||||
const result = await podcastApi.createProject({ ...payload, avatarUrl });
|
||||
await initializeProject(payload, result.projectId);
|
||||
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
|
||||
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers, avatarUrl });
|
||||
setAnalysis(result.analysis);
|
||||
setEstimate(result.estimate);
|
||||
setQueries(result.queries);
|
||||
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
|
||||
setKnobs(payload.knobs);
|
||||
setBudgetCap(payload.budgetCap);
|
||||
setAnnouncement("Analysis complete");
|
||||
|
||||
// Generate presenters AFTER analysis completes (to use analysis insights)
|
||||
// This happens only if no avatar was uploaded
|
||||
if (!avatarUrl && payload.speakers > 0 && result.analysis) {
|
||||
try {
|
||||
setAnnouncement("Generating presenter avatars using AI insights...");
|
||||
const presentersResponse = await podcastApi.generatePresenters(
|
||||
payload.speakers,
|
||||
result.projectId,
|
||||
result.analysis.audience,
|
||||
result.analysis.contentType,
|
||||
result.analysis.topKeywords
|
||||
);
|
||||
if (presentersResponse.avatars && presentersResponse.avatars.length > 0) {
|
||||
// Store the first presenter avatar URL and prompt
|
||||
const firstAvatar = presentersResponse.avatars[0];
|
||||
const prompt = firstAvatar.prompt || null;
|
||||
setProject({
|
||||
id: result.projectId,
|
||||
idea: payload.ideaOrUrl,
|
||||
duration: payload.duration,
|
||||
speakers: payload.speakers,
|
||||
avatarUrl: firstAvatar.avatar_url,
|
||||
avatarPrompt: prompt,
|
||||
avatarPersonaId: firstAvatar.persona_id || presentersResponse.persona_id || null,
|
||||
});
|
||||
setAnnouncement("Analysis complete - Presenter avatars generated");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Presenter generation failed:', error);
|
||||
setAnnouncement("Analysis complete - Avatar generation will happen later");
|
||||
// Continue without presenters - can generate later
|
||||
}
|
||||
} else {
|
||||
setAnnouncement("Analysis complete");
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 429 || error?.response?.data?.detail) {
|
||||
const errorDetail = error.response.data.detail;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, alpha } from "@mui/material";
|
||||
import React, { useCallback, useState, useEffect } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, alpha, Button, CircularProgress, LinearProgress } from "@mui/material";
|
||||
import {
|
||||
PlayArrow as PlayArrowIcon,
|
||||
ArrowBack as ArrowBackIcon,
|
||||
VideoLibrary as VideoLibraryIcon,
|
||||
Download as DownloadIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Script, Knobs, Job } from "./types";
|
||||
import { SecondaryButton } from "./ui";
|
||||
@@ -10,6 +13,7 @@ import { SceneCard } from "./RenderQueue/SceneCard";
|
||||
import { SummaryStats } from "./RenderQueue/SummaryStats";
|
||||
import { GuidancePanel } from "./RenderQueue/GuidancePanel";
|
||||
import { useRenderQueue } from "./RenderQueue/useRenderQueue";
|
||||
import { fetchMediaBlobUrl } from "../../utils/fetchMediaBlobUrl";
|
||||
|
||||
interface RenderQueueProps {
|
||||
projectId: string;
|
||||
@@ -36,6 +40,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
onBack,
|
||||
onError,
|
||||
}) => {
|
||||
const [localError, setLocalError] = useState<string>("");
|
||||
const {
|
||||
rendering,
|
||||
generatingImage,
|
||||
@@ -43,6 +48,10 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
runRender,
|
||||
runImageGeneration,
|
||||
runVideoRender,
|
||||
combiningVideos,
|
||||
combiningProgress,
|
||||
finalVideoUrl,
|
||||
combineFinalVideo,
|
||||
} = useRenderQueue({
|
||||
script,
|
||||
jobs,
|
||||
@@ -52,7 +61,10 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
avatarImageUrl,
|
||||
onUpdateJob,
|
||||
onUpdateScript,
|
||||
onError,
|
||||
onError: (msg) => {
|
||||
setLocalError(msg);
|
||||
onError(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownloadAudio = useCallback((audioUrl: string, title: string) => {
|
||||
@@ -76,11 +88,11 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
title,
|
||||
text: `Check out this podcast episode: ${title}`,
|
||||
url: audioUrl,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// User cancelled or error
|
||||
}
|
||||
} else {
|
||||
} else {
|
||||
// Fallback: copy to clipboard
|
||||
await navigator.clipboard.writeText(audioUrl);
|
||||
alert("Audio URL copied to clipboard!");
|
||||
@@ -91,6 +103,28 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
(jobs.length > 0 && jobs.every((j) => j.status === "completed" && j.imageUrl)) ||
|
||||
(script.scenes.length > 0 && script.scenes.every((s) => s.audioUrl && s.imageUrl));
|
||||
|
||||
const allVideosReady = jobs.length > 0 && jobs.every((j) => j.videoUrl);
|
||||
|
||||
// State for final video blob URL
|
||||
const [finalVideoBlobUrl, setFinalVideoBlobUrl] = useState<string | null>(null);
|
||||
|
||||
// Load final video as blob when URL changes
|
||||
useEffect(() => {
|
||||
if (finalVideoUrl) {
|
||||
fetchMediaBlobUrl(finalVideoUrl)
|
||||
.then((blobUrl) => {
|
||||
if (blobUrl) {
|
||||
setFinalVideoBlobUrl(blobUrl);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load final video blob:", err);
|
||||
});
|
||||
} else {
|
||||
setFinalVideoBlobUrl(null);
|
||||
}
|
||||
}, [finalVideoUrl]);
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{/* Header */}
|
||||
@@ -115,6 +149,24 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Error Display */}
|
||||
{localError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
onClose={() => setLocalError("")}
|
||||
sx={{
|
||||
mb: 3,
|
||||
background: alpha("#ef4444", 0.1),
|
||||
border: "1px solid",
|
||||
borderColor: alpha("#ef4444", 0.3),
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
❌ {localError}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Info Alert */}
|
||||
<Alert severity="info" sx={{ mb: 3, background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
|
||||
<Typography variant="body2">
|
||||
@@ -127,21 +179,21 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
|
||||
{/* Empty State */}
|
||||
{jobs.length === 0 && script.scenes.length === 0 && (
|
||||
<Paper
|
||||
sx={{
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
|
||||
border: "2px dashed rgba(102, 126, 234, 0.3)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, mb: 1 }}>
|
||||
No scenes to render
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mb: 3 }}>
|
||||
Go back to the script editor to generate and approve scenes first.
|
||||
</Typography>
|
||||
</Typography>
|
||||
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
|
||||
Back to Script Editor
|
||||
</SecondaryButton>
|
||||
@@ -166,7 +218,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
avatarImageUrl={avatarImageUrl}
|
||||
onRender={runRender}
|
||||
onImageGenerate={runImageGeneration}
|
||||
onVideoRender={runVideoRender}
|
||||
onVideoGenerate={(sceneId, settings) => runVideoRender(sceneId, settings)}
|
||||
onDownloadAudio={handleDownloadAudio}
|
||||
onDownloadVideo={handleDownloadVideo}
|
||||
onShare={handleShare}
|
||||
@@ -176,6 +228,224 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{/* Final Export Section - Show when all scene videos are ready */}
|
||||
{allVideosReady && (
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
mt: 4,
|
||||
p: 4,
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(6, 182, 212, 0.05) 100%)",
|
||||
border: "2px solid",
|
||||
borderColor: finalVideoUrl ? "success.main" : "info.light",
|
||||
borderRadius: 3,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "4px",
|
||||
background: finalVideoUrl
|
||||
? "linear-gradient(90deg, #10b981 0%, #06b6d4 100%)"
|
||||
: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" alignItems="center" spacing={2}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
background: finalVideoUrl
|
||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.3)",
|
||||
}}
|
||||
>
|
||||
{finalVideoUrl ? (
|
||||
<CheckCircleIcon sx={{ color: "white", fontSize: 32 }} />
|
||||
) : (
|
||||
<VideoLibraryIcon sx={{ color: "white", fontSize: 32 }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: "#0f172a",
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{finalVideoUrl ? "🎉 Final Podcast Ready!" : "🎬 Final Podcast Export"}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
{finalVideoUrl
|
||||
? "Your complete podcast video is ready to download"
|
||||
: `Combine ${script.scenes.length} scene videos into one final podcast`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{finalVideoUrl ? (
|
||||
<Stack spacing={3}>
|
||||
<Alert
|
||||
severity="success"
|
||||
icon={<CheckCircleIcon />}
|
||||
sx={{
|
||||
background: alpha("#10b981", 0.1),
|
||||
border: "1px solid",
|
||||
borderColor: alpha("#10b981", 0.3),
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
✅ Your final podcast video has been created successfully!
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{/* Video Preview */}
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: 900,
|
||||
mx: "auto",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.12)",
|
||||
border: "1px solid",
|
||||
borderColor: alpha("#10b981", 0.2),
|
||||
}}
|
||||
>
|
||||
<video
|
||||
controls
|
||||
src={finalVideoBlobUrl || finalVideoUrl}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "block",
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
>
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
</Box>
|
||||
|
||||
{/* Download Button */}
|
||||
<Stack direction="row" spacing={2} justifyContent="center" sx={{ pt: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={async () => {
|
||||
if (finalVideoBlobUrl) {
|
||||
const link = document.createElement("a");
|
||||
link.href = finalVideoBlobUrl;
|
||||
link.download = `podcast-final-${Date.now()}.mp4`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
|
||||
boxShadow: "0 4px 12px rgba(16, 185, 129, 0.4)",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #059669 0%, #047857 100%)",
|
||||
boxShadow: "0 6px 16px rgba(16, 185, 129, 0.5)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Download Final Podcast
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
background: alpha("#3b82f6", 0.08),
|
||||
border: "1px solid",
|
||||
borderColor: alpha("#3b82f6", 0.2),
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
<strong>Ready to export!</strong> Click below to combine all {script.scenes.length} scene videos into your final podcast video.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{combiningVideos && (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: "#0f172a" }}>
|
||||
{combiningProgress?.message || "Combining videos..."}
|
||||
</Typography>
|
||||
{combiningProgress && (
|
||||
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 600 }}>
|
||||
{combiningProgress.progress.toFixed(0)}%
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant={combiningProgress ? "determinate" : "indeterminate"}
|
||||
value={combiningProgress?.progress || 0}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: alpha("#667eea", 0.1),
|
||||
"& .MuiLinearProgress-bar": {
|
||||
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{combiningProgress && combiningProgress.progress < 100 && (
|
||||
<Typography variant="caption" sx={{ color: "#64748b", mt: 0.5, display: "block" }}>
|
||||
Video encoding in progress. This may take a few minutes...
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
startIcon={combiningVideos ? <CircularProgress size={20} sx={{ color: "white" }} /> : <VideoLibraryIcon />}
|
||||
onClick={combineFinalVideo}
|
||||
disabled={combiningVideos}
|
||||
sx={{
|
||||
py: 2,
|
||||
fontSize: "1.1rem",
|
||||
fontWeight: 700,
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.4)",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%)",
|
||||
boxShadow: "0 6px 16px rgba(102, 126, 234, 0.5)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: alpha("#667eea", 0.5),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{combiningVideos ? "Combining Videos..." : "Combine Scenes into Final Video"}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Footer - Video Generation Focus */}
|
||||
<Paper
|
||||
sx={{
|
||||
@@ -191,13 +461,22 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
|
||||
Back to Script
|
||||
</SecondaryButton>
|
||||
{allScenesCompleted ? (
|
||||
{allVideosReady ? (
|
||||
<Stack spacing={1} alignItems="flex-end">
|
||||
<Typography variant="body1" sx={{ color: "#10b981", fontWeight: 700, fontSize: "1rem" }}>
|
||||
🎉 All scene videos ready!
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
Scroll up to combine them into your final podcast video.
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : allScenesCompleted ? (
|
||||
<Stack spacing={1} alignItems="flex-end">
|
||||
<Typography variant="body1" sx={{ color: "#10b981", fontWeight: 700, fontSize: "1rem" }}>
|
||||
🎉 All scenes ready for video generation!
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
Generate videos for individual scenes or download them.
|
||||
Generate videos for individual scenes above.
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
|
||||
@@ -92,6 +92,9 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
}
|
||||
|
||||
// Has audio - show all action buttons
|
||||
const videoInProgress = rendering !== null;
|
||||
const isCurrentVideo = rendering === scene.id;
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={1.5} justifyContent="flex-end" flexWrap="wrap" useFlexGap>
|
||||
{/* Generate Image */}
|
||||
@@ -114,21 +117,29 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
|
||||
{/* Generate Video */}
|
||||
<PrimaryButton
|
||||
onClick={() => onVideoRender(scene.id)}
|
||||
disabled={isBusy || !hasImage || hasVideo}
|
||||
onClick={() => {
|
||||
onVideoRender(scene.id);
|
||||
}}
|
||||
disabled={isBusy || videoInProgress || !hasImage || hasVideo}
|
||||
startIcon={<VideocamIcon />}
|
||||
tooltip={
|
||||
hasVideo
|
||||
? "Video already generated"
|
||||
: !hasImage
|
||||
? "Generate an image first to create video"
|
||||
: videoInProgress
|
||||
? "A video generation is already running. Please wait..."
|
||||
: isBusy
|
||||
? "Another operation in progress"
|
||||
: "Generate video for this scene"
|
||||
}
|
||||
sx={{ minWidth: 160 }}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
{hasVideo ? "Video Ready" : "Generate Video"}
|
||||
{videoInProgress && isCurrentVideo
|
||||
? "Generating Video..."
|
||||
: hasVideo
|
||||
? "Video Ready"
|
||||
: "Generate Video"}
|
||||
</PrimaryButton>
|
||||
|
||||
{/* Download Video */}
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
OpenInNew as OpenInNewIcon,
|
||||
Videocam as VideocamIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Scene, Job } from "../types";
|
||||
import { Scene, Job, VideoGenerationSettings } from "../types";
|
||||
import { GlassyCard, glassyCardSx } from "../ui";
|
||||
import { InlineAudioPlayer } from "../InlineAudioPlayer";
|
||||
import { SceneActionButtons } from "./SceneActionButtons";
|
||||
import { aiApiClient } from "../../../api/client";
|
||||
import { fetchMediaBlobUrl } from "../../../utils/fetchMediaBlobUrl";
|
||||
import { VideoRegenerateModal } from "./VideoRegenerateModal";
|
||||
|
||||
interface SceneCardProps {
|
||||
scene: Scene;
|
||||
@@ -22,7 +24,7 @@ interface SceneCardProps {
|
||||
avatarImageUrl?: string | null;
|
||||
onRender: (sceneId: string, mode: "preview" | "full") => void;
|
||||
onImageGenerate: (sceneId: string) => void;
|
||||
onVideoRender: (sceneId: string) => void;
|
||||
onVideoGenerate: (sceneId: string, settings: VideoGenerationSettings) => void;
|
||||
onDownloadAudio: (audioUrl: string, title: string) => void;
|
||||
onDownloadVideo: (videoUrl: string, title: string) => void;
|
||||
onShare: (audioUrl: string, title: string) => void;
|
||||
@@ -75,7 +77,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
avatarImageUrl,
|
||||
onRender,
|
||||
onImageGenerate,
|
||||
onVideoRender,
|
||||
onVideoGenerate,
|
||||
onDownloadAudio,
|
||||
onDownloadVideo,
|
||||
onShare,
|
||||
@@ -89,8 +91,27 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
const status = job?.status || (hasAudio ? "completed" : "idle");
|
||||
const initials = getInitials(scene.title);
|
||||
|
||||
|
||||
// Load image as blob if it's an authenticated endpoint
|
||||
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
|
||||
const [videoBlobUrl, setVideoBlobUrl] = useState<string | null>(null);
|
||||
const [showVideoModal, setShowVideoModal] = useState(false);
|
||||
const [initialVideoPrompt, setInitialVideoPrompt] = useState<string>("");
|
||||
|
||||
// Prepare a simple default prompt based on the scene title/description
|
||||
useEffect(() => {
|
||||
const baseTitle = (scene.title || "").trim();
|
||||
const description = (scene as any).description as string | undefined;
|
||||
const descSnippet = (description || "").split(".")[0]?.trim();
|
||||
let prompt = baseTitle;
|
||||
if (!prompt && descSnippet) {
|
||||
prompt = descSnippet;
|
||||
}
|
||||
if (!prompt) {
|
||||
prompt = "Professional podcast scene with subtle movement";
|
||||
}
|
||||
setInitialVideoPrompt(prompt);
|
||||
}, [scene]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) {
|
||||
@@ -98,14 +119,11 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SceneCard] Loading image:', { imageUrl, hasImage, sceneId: scene.id });
|
||||
|
||||
// Check if this is a podcast image endpoint that requires authentication
|
||||
const isPodcastImage = imageUrl.includes('/api/podcast/images/') || imageUrl.includes('/api/story/images/');
|
||||
|
||||
if (!isPodcastImage) {
|
||||
// Regular URL (external), use directly
|
||||
console.log('[SceneCard] Using external image URL directly');
|
||||
setImageBlobUrl(imageUrl);
|
||||
return;
|
||||
}
|
||||
@@ -134,22 +152,17 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
// Remove query parameters if present
|
||||
imagePath = imagePath.split('?')[0];
|
||||
|
||||
console.log('[SceneCard] Fetching image blob from:', imagePath);
|
||||
|
||||
const response = await aiApiClient.get(imagePath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
if (!isMounted || imageUrl !== currentImageUrl) {
|
||||
console.log('[SceneCard] Component unmounted or URL changed, skipping blob URL set');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = response.data;
|
||||
const newBlobUrl = URL.createObjectURL(blob);
|
||||
|
||||
console.log('[SceneCard] Image blob loaded successfully, created blob URL');
|
||||
|
||||
setImageBlobUrl((prevBlobUrl) => {
|
||||
// Clean up previous blob URL if exists
|
||||
if (prevBlobUrl && prevBlobUrl !== newBlobUrl && prevBlobUrl.startsWith('blob:')) {
|
||||
@@ -184,11 +197,9 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
const token = localStorage.getItem('clerk_dashboard_token') || '';
|
||||
if (token) {
|
||||
const urlWithToken = `${fallbackPath}?token=${encodeURIComponent(token)}`;
|
||||
console.log('[SceneCard] Trying URL with query token');
|
||||
setImageBlobUrl(urlWithToken);
|
||||
} else {
|
||||
// Fallback to original URL
|
||||
console.log('[SceneCard] No token available, using original URL');
|
||||
setImageBlobUrl(imageUrl);
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
@@ -213,6 +224,39 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
};
|
||||
}, [imageUrl, hasImage, scene.id]);
|
||||
|
||||
// Load video as blob when videoUrl changes (using Story Writer's utility)
|
||||
useEffect(() => {
|
||||
if (!job?.videoUrl) {
|
||||
setVideoBlobUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let currentBlobUrl: string | null = null;
|
||||
|
||||
fetchMediaBlobUrl(job.videoUrl)
|
||||
.then((blobUrl) => {
|
||||
if (blobUrl) {
|
||||
currentBlobUrl = blobUrl;
|
||||
setVideoBlobUrl(blobUrl);
|
||||
} else {
|
||||
// File not found (404) - clear the blob URL
|
||||
console.warn('[SceneCard] Video file not found (404):', job.videoUrl);
|
||||
setVideoBlobUrl(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[SceneCard] Failed to load video blob:', err);
|
||||
setVideoBlobUrl(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup blob URL when component unmounts or URL changes
|
||||
if (currentBlobUrl) {
|
||||
URL.revokeObjectURL(currentBlobUrl);
|
||||
}
|
||||
};
|
||||
}, [job?.videoUrl]);
|
||||
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={2}>
|
||||
@@ -279,13 +323,12 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{hasVideo && job?.videoUrl && (
|
||||
{hasVideo && videoBlobUrl && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Box
|
||||
component="a"
|
||||
href={job.videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={videoBlobUrl}
|
||||
download={`${scene.title.replace(/[^a-z0-9]/gi, '_')}_video.mp4`}
|
||||
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<VideocamIcon sx={{ fontSize: 16 }} />
|
||||
@@ -350,8 +393,57 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
<InlineAudioPlayer audioUrl={audioUrl} title={scene.title} />
|
||||
)}
|
||||
|
||||
{/* Image Preview */}
|
||||
{hasImage && (imageBlobUrl || imageUrl) && (
|
||||
{/* Video Preview - Show video if available, otherwise show image */}
|
||||
{hasVideo && videoBlobUrl ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
border: "2px solid rgba(56,189,248,0.5)",
|
||||
background: alpha("#0f172a", 0.85),
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="video"
|
||||
src={videoBlobUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
maxHeight: 420,
|
||||
objectFit: "cover",
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const videoElement = e.currentTarget as HTMLVideoElement;
|
||||
console.error("[SceneCard] Video failed to load:", {
|
||||
originalUrl: job?.videoUrl,
|
||||
networkState: videoElement.networkState,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
bgcolor: "rgba(56,189,248,0.9)",
|
||||
color: "white",
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
VIDEO
|
||||
</Box>
|
||||
</Box>
|
||||
) : hasImage && (imageBlobUrl || imageUrl) ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
@@ -373,21 +465,14 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
objectFit: "cover",
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error('[SceneCard] Image failed to load:', {
|
||||
console.error("[SceneCard] Image failed to load:", {
|
||||
src: e.currentTarget.src,
|
||||
imageUrl,
|
||||
imageBlobUrl,
|
||||
hasImage,
|
||||
});
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('[SceneCard] Image loaded successfully:', {
|
||||
src: imageBlobUrl || imageUrl,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<SceneActionButtons
|
||||
@@ -402,12 +487,25 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
isBusy={isBusy}
|
||||
onRender={onRender}
|
||||
onImageGenerate={onImageGenerate}
|
||||
onVideoRender={onVideoRender}
|
||||
onVideoRender={() => setShowVideoModal(true)}
|
||||
onDownloadAudio={onDownloadAudio}
|
||||
onDownloadVideo={onDownloadVideo}
|
||||
onShare={onShare}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
{/* Video Generation Settings Modal */}
|
||||
<VideoRegenerateModal
|
||||
open={showVideoModal}
|
||||
onClose={() => setShowVideoModal(false)}
|
||||
onGenerate={(settings: VideoGenerationSettings) => {
|
||||
setShowVideoModal(false);
|
||||
onVideoGenerate(scene.id, settings);
|
||||
}}
|
||||
initialPrompt={initialVideoPrompt}
|
||||
initialResolution="480p"
|
||||
initialSeed={-1}
|
||||
/>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Stack,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import { Info as InfoIcon } from "@mui/icons-material";
|
||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||
import type { VideoGenerationSettings } from "../types";
|
||||
|
||||
interface VideoRegenerateModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onGenerate: (settings: VideoGenerationSettings) => void;
|
||||
initialPrompt: string;
|
||||
initialResolution?: "480p" | "720p";
|
||||
initialSeed?: number | null;
|
||||
}
|
||||
|
||||
export const VideoRegenerateModal: React.FC<VideoRegenerateModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onGenerate,
|
||||
initialPrompt,
|
||||
initialResolution = "480p",
|
||||
initialSeed = -1,
|
||||
}) => {
|
||||
const [prompt, setPrompt] = useState(initialPrompt);
|
||||
const [resolution, setResolution] = useState<"480p" | "720p">(initialResolution);
|
||||
const [seed, setSeed] = useState<string>(initialSeed != null && initialSeed !== -1 ? String(initialSeed) : "");
|
||||
const [maskImageUrl, setMaskImageUrl] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setPrompt(initialPrompt);
|
||||
setResolution(initialResolution);
|
||||
}, [initialResolution, initialPrompt]);
|
||||
|
||||
const handleGenerate = () => {
|
||||
const parsedSeed = seed.trim() === "" ? undefined : Number.isNaN(Number(seed)) ? undefined : Number(seed);
|
||||
const settings: VideoGenerationSettings = {
|
||||
prompt: prompt.trim(),
|
||||
resolution,
|
||||
seed: parsedSeed,
|
||||
maskImageUrl: maskImageUrl.trim() || undefined,
|
||||
};
|
||||
onGenerate(settings);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: "rgba(15, 23, 42, 0.96)",
|
||||
backdropFilter: "blur(18px)",
|
||||
borderRadius: 4,
|
||||
border: "1px solid rgba(148, 163, 184, 0.4)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h6" sx={{ color: "white", fontWeight: 600 }}>
|
||||
Configure Video Generation
|
||||
</Typography>
|
||||
<Tooltip title="Adjust how your talking-head video is rendered. These settings control resolution, prompt, and animation seed.">
|
||||
<InfoIcon sx={{ color: "rgba(148,163,184,0.9)" }} />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: "rgba(148,163,184,0.9)", mt: 1 }}>
|
||||
Fine-tune how this scene is animated. InfiniteTalk is audio-driven, so use the prompt to describe the visual
|
||||
look and feel you want while keeping it concise.
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Stack spacing={3} sx={{ mt: 1 }}>
|
||||
{/* Prompt */}
|
||||
<Box>
|
||||
<FormLabel sx={{ color: "rgba(248,250,252,0.9)", mb: 0.5 }}>Visual prompt</FormLabel>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
fullWidth
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Short description of how the scene should look (lighting, mood, camera feel, etc.)"
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
sx: {
|
||||
bgcolor: "rgba(15,23,42,0.9)",
|
||||
color: "white",
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(148,163,184,0.4)",
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(125,211,252,0.8)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
InputLabelProps={{
|
||||
sx: { color: "rgba(148,163,184,0.9)" },
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "rgba(148,163,184,0.9)", mt: 0.5, display: "block" }}>
|
||||
Example: "Modern podcast studio with soft lighting, the host framed center, gentle camera movement."
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Resolution */}
|
||||
<Box>
|
||||
<FormLabel sx={{ color: "rgba(248,250,252,0.9)", mb: 1 }}>Resolution & quality</FormLabel>
|
||||
<RadioGroup
|
||||
row
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value as "480p" | "720p")}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="480p"
|
||||
control={<Radio color="primary" />}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2">480p (Recommended)</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Faster render, lower cost, great for previews & social
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="720p"
|
||||
control={<Radio color="primary" />}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2">720p (Higher quality)</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Sharper video, slightly higher cost and render time
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
{/* Seed & advanced options */}
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<FormControl fullWidth>
|
||||
<FormLabel sx={{ color: "rgba(248,250,252,0.9)", mb: 0.5 }}>Seed (optional)</FormLabel>
|
||||
<TextField
|
||||
type="number"
|
||||
value={seed}
|
||||
onChange={(e) => setSeed(e.target.value)}
|
||||
placeholder="Random each time if left empty"
|
||||
InputProps={{
|
||||
sx: {
|
||||
bgcolor: "rgba(15,23,42,0.9)",
|
||||
color: "white",
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(148,163,184,0.4)",
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(125,211,252,0.8)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "rgba(148,163,184,0.9)", mt: 0.5 }}>
|
||||
Use the same seed to get a similar animation style across multiple scenes.
|
||||
</Typography>
|
||||
</FormControl>
|
||||
|
||||
<FormControl full-width="true">
|
||||
<FormLabel sx={{ color: "rgba(248,250,252,0.9)", mb: 0.5 }}>Mask image URL (optional)</FormLabel>
|
||||
<TextField
|
||||
value={maskImageUrl}
|
||||
onChange={(e) => setMaskImageUrl(e.target.value)}
|
||||
placeholder="e.g. /api/podcast/images/your_avatar_mask.png"
|
||||
InputProps={{
|
||||
sx: {
|
||||
bgcolor: "rgba(15,23,42,0.9)",
|
||||
color: "white",
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(148,163,184,0.4)",
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(125,211,252,0.8)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "rgba(148,163,184,0.9)", mt: 0.5 }}>
|
||||
Optional: limit animation to a specific region (e.g. face) by providing a mask image URL. Leave empty to
|
||||
animate the whole frame.
|
||||
</Typography>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 2.5, pt: 0, justifyContent: "space-between" }}>
|
||||
<Typography variant="caption" sx={{ color: "rgba(148,163,184,0.9)" }}>
|
||||
Estimated cost at 480p is lower than 720p. You'll only be billed for successful renders.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton onClick={handleGenerate}>Generate Video</PrimaryButton>
|
||||
</Stack>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Script, Knobs, Job, RenderJobResult, TaskStatus } from "../types";
|
||||
import { Script, Knobs, Job, RenderJobResult, TaskStatus, VideoGenerationSettings } from "../types";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
|
||||
interface UseRenderQueueProps {
|
||||
@@ -36,7 +36,11 @@ export const useRenderQueue = ({
|
||||
duration: number;
|
||||
sceneCount: number;
|
||||
} | null>(null);
|
||||
const [combiningVideos, setCombiningVideos] = useState(false);
|
||||
const [finalVideoUrl, setFinalVideoUrl] = useState<string | null>(null);
|
||||
const [combiningProgress, setCombiningProgress] = useState<{ progress: number; message: string } | null>(null);
|
||||
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const pollingErrorCounts = useRef<Map<string, number>>(new Map());
|
||||
|
||||
// Cleanup polling intervals on unmount
|
||||
useEffect(() => {
|
||||
@@ -44,10 +48,11 @@ export const useRenderQueue = ({
|
||||
return () => {
|
||||
intervals.forEach((interval) => clearInterval(interval));
|
||||
intervals.clear();
|
||||
pollingErrorCounts.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize jobs if empty
|
||||
// Initialize jobs if empty (audio/image only)
|
||||
useEffect(() => {
|
||||
if (jobs.length === 0 && script.scenes.length > 0) {
|
||||
const initialJobs: Job[] = script.scenes.map((s) => {
|
||||
@@ -59,7 +64,7 @@ export const useRenderQueue = ({
|
||||
progress: hasExistingAudio ? 100 : 0,
|
||||
previewUrl: null,
|
||||
finalUrl: hasExistingAudio ? s.audioUrl || null : null,
|
||||
imageUrl: s.imageUrl || null, // Include existing imageUrl from scene
|
||||
imageUrl: s.imageUrl || null,
|
||||
jobId: null,
|
||||
};
|
||||
});
|
||||
@@ -67,25 +72,201 @@ export const useRenderQueue = ({
|
||||
onUpdateJob(job.sceneId, job);
|
||||
});
|
||||
}
|
||||
}, [script.scenes.length, jobs.length, onUpdateJob, script.scenes]);
|
||||
}, [jobs.length, script.scenes.length, onUpdateJob, script.scenes]);
|
||||
|
||||
// Load final video URL from project on mount (for persistence across reloads)
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
podcastApi
|
||||
.loadProject(projectId)
|
||||
.then((project) => {
|
||||
if (project.final_video_url) {
|
||||
console.log("[useRenderQueue] Loaded final video URL from project:", project.final_video_url);
|
||||
setFinalVideoUrl(project.final_video_url);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[useRenderQueue] Failed to load project for final video URL:", error);
|
||||
// Don't show error to user - this is just for restoring state
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
// Always try to attach existing videos to scenes (even after reloads)
|
||||
useEffect(() => {
|
||||
if (script.scenes.length === 0) return;
|
||||
|
||||
podcastApi
|
||||
.listVideos(projectId)
|
||||
.then((result) => {
|
||||
const videoMap = new Map<number, string>();
|
||||
|
||||
result.videos.forEach((video) => {
|
||||
// Use the most recent video for each scene number
|
||||
if (!videoMap.has(video.scene_number)) {
|
||||
// Store the raw video URL - SceneCard will handle authentication via blob loading
|
||||
videoMap.set(video.scene_number, video.video_url);
|
||||
}
|
||||
});
|
||||
|
||||
script.scenes.forEach((scene) => {
|
||||
const sceneNumberMatch = scene.id.match(/\d+/);
|
||||
const sceneNumber = sceneNumberMatch ? parseInt(sceneNumberMatch[0], 10) : null;
|
||||
|
||||
if (sceneNumber === null) return;
|
||||
|
||||
const videoUrl = videoMap.get(sceneNumber);
|
||||
if (!videoUrl) return;
|
||||
|
||||
const job = jobs.find((j) => j.sceneId === scene.id);
|
||||
|
||||
// Avoid redundant updates
|
||||
if (job?.videoUrl === videoUrl) return;
|
||||
|
||||
onUpdateJob(scene.id, {
|
||||
sceneId: scene.id,
|
||||
title: scene.title,
|
||||
videoUrl,
|
||||
status: "completed" as const,
|
||||
progress: 100,
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[useRenderQueue] Failed to list existing videos:", error);
|
||||
});
|
||||
}, [projectId, script.scenes, jobs, onUpdateJob]);
|
||||
|
||||
// Periodic check to rescue videos that were generated but not detected by polling
|
||||
useEffect(() => {
|
||||
if (rendering && script.scenes.length > 0) {
|
||||
const rescueInterval = setInterval(async () => {
|
||||
// Check for videos every 2 minutes while rendering is active
|
||||
try {
|
||||
const videoList = await podcastApi.listVideos(projectId);
|
||||
|
||||
const videoMap = new Map<number, string>();
|
||||
videoList.videos.forEach((video) => {
|
||||
if (!videoMap.has(video.scene_number)) {
|
||||
// Store the raw video URL - SceneCard will handle authentication via blob loading
|
||||
videoMap.set(video.scene_number, video.video_url);
|
||||
}
|
||||
});
|
||||
|
||||
// Update jobs for scenes that have videos but no videoUrl set
|
||||
script.scenes.forEach((scene) => {
|
||||
const sceneNumberMatch = scene.id.match(/\d+/);
|
||||
const sceneNumber = sceneNumberMatch ? parseInt(sceneNumberMatch[0], 10) : null;
|
||||
if (sceneNumber !== null) {
|
||||
const videoUrl = videoMap.get(sceneNumber);
|
||||
const job = jobs.find((j) => j.sceneId === scene.id);
|
||||
|
||||
if (videoUrl) {
|
||||
if (!job) {
|
||||
onUpdateJob(scene.id, {
|
||||
sceneId: scene.id,
|
||||
title: scene.title,
|
||||
status: "completed" as const,
|
||||
progress: 100,
|
||||
videoUrl,
|
||||
});
|
||||
} else if (!job.videoUrl) {
|
||||
onUpdateJob(scene.id, { videoUrl, status: "completed" as const, progress: 100 });
|
||||
// If this was the rendering scene, stop rendering
|
||||
if (rendering === scene.id) {
|
||||
setRendering(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[useRenderQueue] Failed to rescue videos:", error);
|
||||
}
|
||||
}, 120000); // Check every 2 minutes
|
||||
|
||||
return () => clearInterval(rescueInterval);
|
||||
}
|
||||
}, [rendering, script.scenes, jobs, projectId, onUpdateJob]);
|
||||
|
||||
const getScene = useCallback((sceneId: string) => script.scenes.find((s) => s.id === sceneId), [script.scenes]);
|
||||
|
||||
const pollTaskStatus = useCallback(async (taskId: string, sceneId: string) => {
|
||||
try {
|
||||
const status: TaskStatus = await podcastApi.pollTaskStatus(taskId);
|
||||
const status: TaskStatus | null = await podcastApi.pollTaskStatus(taskId);
|
||||
|
||||
// Handle null response (task not found)
|
||||
if (!status) {
|
||||
const errorCount = (pollingErrorCounts.current.get(sceneId) || 0) + 1;
|
||||
pollingErrorCounts.current.set(sceneId, errorCount);
|
||||
|
||||
// Stop polling after 3 consecutive "task not found" errors
|
||||
if (errorCount >= 3) {
|
||||
onUpdateJob(sceneId, { status: "failed", progress: 0 });
|
||||
const interval = pollingIntervals.current.get(sceneId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
pollingIntervals.current.delete(sceneId);
|
||||
}
|
||||
pollingErrorCounts.current.delete(sceneId);
|
||||
setRendering(null);
|
||||
onError("Video generation task not found. The task may have expired or been cancelled.");
|
||||
return true; // Stop polling
|
||||
}
|
||||
return false; // Continue polling (might be transient)
|
||||
}
|
||||
|
||||
// Reset error count on successful poll
|
||||
pollingErrorCounts.current.delete(sceneId);
|
||||
|
||||
onUpdateJob(sceneId, {
|
||||
progress: status.progress ?? 0,
|
||||
status: status.status === "completed" ? "completed" : status.status === "failed" ? "failed" : "running",
|
||||
});
|
||||
|
||||
if (status.status === "completed" && status.result) {
|
||||
// Check for completion - handle both "completed" and "processing" with 100% progress
|
||||
const isCompleted = status.status === "completed" || (status.status === "processing" && status.progress === 100);
|
||||
|
||||
if (isCompleted && status.result) {
|
||||
const result = status.result;
|
||||
console.log("[useRenderQueue] Task completed, extracting video URL", {
|
||||
result,
|
||||
video_url: result.video_url,
|
||||
status: status.status,
|
||||
progress: status.progress,
|
||||
});
|
||||
|
||||
let videoUrl = result.video_url;
|
||||
if (!videoUrl) {
|
||||
console.error("[useRenderQueue] No video_url in result! Attempting to rescue from file system...", { result });
|
||||
// Try to rescue: check if video exists for this scene
|
||||
const sceneNumberMatch = getScene(sceneId)?.id.match(/\d+/);
|
||||
const sceneNumber = sceneNumberMatch ? parseInt(sceneNumberMatch[0], 10) : null;
|
||||
if (sceneNumber !== null) {
|
||||
podcastApi
|
||||
.listVideos(projectId)
|
||||
.then((videoList) => {
|
||||
const sceneVideo = videoList.videos.find((v) => v.scene_number === sceneNumber);
|
||||
if (sceneVideo) {
|
||||
// Store the raw video URL - SceneCard will handle authentication via blob loading
|
||||
onUpdateJob(sceneId, {
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
videoUrl: sceneVideo.video_url,
|
||||
cost: result.cost || 0,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("[useRenderQueue] Failed to rescue video:", err));
|
||||
}
|
||||
return true; // Stop polling
|
||||
}
|
||||
|
||||
// Store the raw video URL - SceneCard will handle authentication via blob loading
|
||||
onUpdateJob(sceneId, {
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
videoUrl: result.video_url,
|
||||
videoUrl,
|
||||
cost: result.cost,
|
||||
});
|
||||
|
||||
@@ -94,20 +275,62 @@ export const useRenderQueue = ({
|
||||
clearInterval(interval);
|
||||
pollingIntervals.current.delete(sceneId);
|
||||
}
|
||||
setRendering(null);
|
||||
return true; // Stop polling
|
||||
} else if (status.status === "failed") {
|
||||
// Extract user-friendly error message
|
||||
let errorMessage = "Video generation failed";
|
||||
if (status.error) {
|
||||
// Try to extract meaningful error from various formats
|
||||
const errorStr = status.error;
|
||||
if (errorStr.includes("Insufficient credits")) {
|
||||
errorMessage = "Video generation failed: Insufficient WaveSpeed credits. Please top up your account.";
|
||||
} else if (errorStr.includes("HTTPException") || errorStr.includes("502")) {
|
||||
// Extract the actual error message from HTTPException details
|
||||
const match = errorStr.match(/message[":\s]+"([^"]+)"/i) || errorStr.match(/detail[":\s]+"([^"]+)"/i);
|
||||
if (match && match[1]) {
|
||||
errorMessage = `Video generation failed: ${match[1]}`;
|
||||
} else {
|
||||
errorMessage = `Video generation failed: ${errorStr}`;
|
||||
}
|
||||
} else {
|
||||
errorMessage = `Video generation failed: ${errorStr}`;
|
||||
}
|
||||
}
|
||||
|
||||
onUpdateJob(sceneId, { status: "failed", progress: 0 });
|
||||
const interval = pollingIntervals.current.get(sceneId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
pollingIntervals.current.delete(sceneId);
|
||||
}
|
||||
onError(status.error || "Video generation failed");
|
||||
pollingErrorCounts.current.delete(sceneId);
|
||||
setRendering(null);
|
||||
onError(errorMessage);
|
||||
return true; // Stop polling
|
||||
}
|
||||
|
||||
return status.status === "completed" || status.status === "failed";
|
||||
return false; // Continue polling
|
||||
} catch (error) {
|
||||
console.error("Error polling task status:", error);
|
||||
return false;
|
||||
const errorCount = (pollingErrorCounts.current.get(sceneId) || 0) + 1;
|
||||
pollingErrorCounts.current.set(sceneId, errorCount);
|
||||
|
||||
// Stop polling after 5 consecutive network errors
|
||||
if (errorCount >= 5) {
|
||||
onUpdateJob(sceneId, { status: "failed", progress: 0 });
|
||||
const interval = pollingIntervals.current.get(sceneId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
pollingIntervals.current.delete(sceneId);
|
||||
}
|
||||
pollingErrorCounts.current.delete(sceneId);
|
||||
setRendering(null);
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
onError(`Video generation failed: Unable to check status. ${errorMsg}`);
|
||||
return true; // Stop polling
|
||||
}
|
||||
return false; // Continue polling (might be transient network error)
|
||||
}
|
||||
}, [onUpdateJob, onError]);
|
||||
|
||||
@@ -217,6 +440,7 @@ export const useRenderQueue = ({
|
||||
sceneId: scene.id,
|
||||
sceneTitle: scene.title,
|
||||
sceneContent: sceneContent,
|
||||
baseAvatarUrl: avatarImageUrl || undefined, // Use base avatar if available
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
});
|
||||
@@ -239,68 +463,112 @@ export const useRenderQueue = ({
|
||||
} finally {
|
||||
setGeneratingImage(null);
|
||||
}
|
||||
}, [generatingImage, getScene, onUpdateJob, onError]);
|
||||
}, [generatingImage, getScene, avatarImageUrl, onUpdateJob, onError, script]);
|
||||
|
||||
const runVideoRender = useCallback(async (sceneId: string) => {
|
||||
if (rendering && rendering !== sceneId) return;
|
||||
const scene = getScene(sceneId);
|
||||
if (!scene) return;
|
||||
|
||||
const sceneImageUrl = scene.imageUrl || avatarImageUrl;
|
||||
if (!sceneImageUrl) {
|
||||
onError("Scene image is required for video generation. Please generate images for scenes first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const job = jobs.find((j) => j.sceneId === sceneId);
|
||||
if (!job?.finalUrl) {
|
||||
onError("Please generate audio first before creating video.");
|
||||
return;
|
||||
}
|
||||
|
||||
const estimatedCost = 0.30;
|
||||
if (budgetCap && budgetCap > 0) {
|
||||
const currentSpent = jobs
|
||||
.filter((j) => j.status === "completed" && j.cost)
|
||||
.reduce((sum, j) => sum + (j.cost || 0), 0);
|
||||
|
||||
if (currentSpent + estimatedCost > budgetCap) {
|
||||
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(2)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
|
||||
const runVideoRender = useCallback(
|
||||
async (sceneId: string, settings?: VideoGenerationSettings) => {
|
||||
if (rendering && rendering !== sceneId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setRendering(sceneId);
|
||||
onUpdateJob(sceneId, {
|
||||
status: "running",
|
||||
progress: 5,
|
||||
});
|
||||
const scene = getScene(sceneId);
|
||||
if (!scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await podcastApi.generateVideo({
|
||||
projectId,
|
||||
sceneId,
|
||||
sceneTitle: scene.title,
|
||||
audioUrl: job.finalUrl,
|
||||
avatarImageUrl: sceneImageUrl,
|
||||
resolution: knobs.resolution || "720p",
|
||||
});
|
||||
// Guard: require image and audio before calling expensive video gen
|
||||
const sceneImageUrl = scene.imageUrl || avatarImageUrl;
|
||||
if (!sceneImageUrl) {
|
||||
onError("Scene image is required for video generation. Please generate images for scenes first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const job = jobs.find((j) => j.sceneId === sceneId);
|
||||
// Use job.finalUrl if available, otherwise fall back to scene.audioUrl (from Script Editor)
|
||||
const audioUrl = job?.finalUrl || scene.audioUrl;
|
||||
if (!audioUrl || audioUrl.startsWith("blob:")) {
|
||||
onError("Please generate audio first before creating video.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: ensure every scene has audio and image before enabling render queue video
|
||||
const allScenesHaveAudio = script.scenes.every((s) => s.audioUrl && !s.audioUrl.startsWith("blob:"));
|
||||
const allScenesHaveImage = script.scenes.every((s) => s.imageUrl);
|
||||
if (!allScenesHaveAudio || !allScenesHaveImage) {
|
||||
onError("Please ensure all scenes have both audio and image before generating video.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolution & simple cost heuristic (default 480p for lower cost)
|
||||
const targetResolution: "480p" | "720p" =
|
||||
settings?.resolution || (knobs.resolution as "480p" | "720p") || "480p";
|
||||
const baseCost = 0.3; // 5s at 720p
|
||||
const estimatedCost = targetResolution === "480p" ? baseCost / 2 : baseCost;
|
||||
|
||||
if (budgetCap && budgetCap > 0) {
|
||||
const currentSpent = jobs
|
||||
.filter((j) => j.status === "completed" && j.cost)
|
||||
.reduce((sum, j) => sum + (j.cost || 0), 0);
|
||||
|
||||
if (currentSpent + estimatedCost > budgetCap) {
|
||||
onError(
|
||||
`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(
|
||||
2
|
||||
)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setRendering(sceneId);
|
||||
onUpdateJob(sceneId, {
|
||||
taskId: result.taskId,
|
||||
status: "running",
|
||||
progress: 5,
|
||||
});
|
||||
|
||||
startPolling(result.taskId, sceneId);
|
||||
} catch (error) {
|
||||
onUpdateJob(sceneId, { status: "failed", progress: 0 });
|
||||
const message = error instanceof Error ? error.message : "Video generation failed";
|
||||
onError(message);
|
||||
} finally {
|
||||
setRendering(null);
|
||||
}
|
||||
}, [rendering, getScene, avatarImageUrl, jobs, budgetCap, projectId, knobs, onUpdateJob, onError, startPolling]);
|
||||
try {
|
||||
console.log("[useRenderQueue] Starting video generation", {
|
||||
sceneId,
|
||||
sceneTitle: scene.title,
|
||||
audioUrl,
|
||||
avatarImageUrl: sceneImageUrl,
|
||||
resolution: targetResolution,
|
||||
prompt: settings?.prompt,
|
||||
seed: settings?.seed,
|
||||
maskImageUrl: settings?.maskImageUrl,
|
||||
});
|
||||
|
||||
const result = await podcastApi.generateVideo({
|
||||
projectId,
|
||||
sceneId,
|
||||
sceneTitle: scene.title,
|
||||
audioUrl,
|
||||
avatarImageUrl: sceneImageUrl,
|
||||
resolution: targetResolution,
|
||||
prompt: settings?.prompt || undefined,
|
||||
seed: settings?.seed ?? -1,
|
||||
maskImageUrl: settings?.maskImageUrl || undefined,
|
||||
});
|
||||
|
||||
if (!result.taskId) {
|
||||
throw new Error("Backend did not return a task ID. Response: " + JSON.stringify(result));
|
||||
}
|
||||
|
||||
onUpdateJob(sceneId, {
|
||||
taskId: result.taskId,
|
||||
status: "running",
|
||||
progress: 5,
|
||||
});
|
||||
|
||||
startPolling(result.taskId, sceneId);
|
||||
} catch (error) {
|
||||
onUpdateJob(sceneId, { status: "failed", progress: 0 });
|
||||
const message = error instanceof Error ? error.message : "Video generation failed";
|
||||
onError(message);
|
||||
}
|
||||
},
|
||||
[rendering, getScene, avatarImageUrl, jobs, budgetCap, projectId, knobs, onUpdateJob, onError, script.scenes, startPolling]
|
||||
);
|
||||
|
||||
const combineAudio = useCallback(async () => {
|
||||
try {
|
||||
@@ -361,16 +629,151 @@ export const useRenderQueue = ({
|
||||
}
|
||||
}, [script.scenes, jobs, projectId, onError]);
|
||||
|
||||
const combineFinalVideo = useCallback(async () => {
|
||||
try {
|
||||
setCombiningVideos(true);
|
||||
onError("");
|
||||
|
||||
// Collect all scene video URLs
|
||||
const sceneVideoUrls: string[] = [];
|
||||
for (const scene of script.scenes) {
|
||||
const job = jobs.find((j) => j.sceneId === scene.id);
|
||||
if (!job?.videoUrl) {
|
||||
throw new Error(`Scene "${scene.title}" is missing a video. Please generate videos for all scenes first.`);
|
||||
}
|
||||
// Remove blob URLs and query params - use the API path only
|
||||
let videoUrl = job.videoUrl;
|
||||
if (videoUrl.startsWith("blob:")) {
|
||||
throw new Error(`Scene "${scene.title}" has a blob URL. Cannot combine blob URLs. Please use API URLs.`);
|
||||
}
|
||||
videoUrl = videoUrl.split("?")[0]; // Remove query params
|
||||
sceneVideoUrls.push(videoUrl);
|
||||
}
|
||||
|
||||
console.log("[combineFinalVideo] Starting combination with", sceneVideoUrls.length, "videos");
|
||||
|
||||
// Start combination task
|
||||
const result = await podcastApi.combineVideos({
|
||||
projectId,
|
||||
sceneVideoUrls,
|
||||
podcastTitle: script.scenes[0]?.title || "Podcast",
|
||||
});
|
||||
|
||||
console.log("[combineFinalVideo] Task created:", result.taskId);
|
||||
|
||||
// Poll for completion
|
||||
const taskId = result.taskId;
|
||||
let done = false;
|
||||
let pollCount = 0;
|
||||
const maxPolls = 300; // 10 minutes max (300 * 2 seconds) - encoding can take time
|
||||
let lastProgress = 0;
|
||||
let lastMessage = "Starting video combination...";
|
||||
|
||||
while (!done && pollCount < maxPolls) {
|
||||
await new Promise((r) => setTimeout(r, 2000)); // Poll every 2 seconds
|
||||
pollCount++;
|
||||
|
||||
const status = await podcastApi.pollTaskStatus(taskId);
|
||||
|
||||
// Update progress and message for user feedback
|
||||
if (status) {
|
||||
const currentProgress = status.progress ?? 0;
|
||||
const currentMessage = status.message || "Processing...";
|
||||
|
||||
// Update UI with progress
|
||||
setCombiningProgress({
|
||||
progress: currentProgress,
|
||||
message: currentMessage,
|
||||
});
|
||||
|
||||
// Only log if progress or message changed to reduce noise
|
||||
if (currentProgress !== lastProgress || currentMessage !== lastMessage) {
|
||||
console.log(
|
||||
`[combineFinalVideo] Poll ${pollCount}: ${status.status} | ` +
|
||||
`Progress: ${currentProgress.toFixed(1)}% | Message: ${currentMessage}`
|
||||
);
|
||||
lastProgress = currentProgress;
|
||||
lastMessage = currentMessage;
|
||||
}
|
||||
} else {
|
||||
console.log(`[combineFinalVideo] Poll ${pollCount}: No status yet...`);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
// Don't fail immediately - task might still be initializing
|
||||
if (pollCount < 10) {
|
||||
continue; // Wait up to 20 seconds for task to appear
|
||||
}
|
||||
console.error("[combineFinalVideo] Task not found after 10 polls");
|
||||
throw new Error("Task not found. Video combination may have failed on the server. Please try again.");
|
||||
}
|
||||
|
||||
if (status.status === "completed") {
|
||||
done = true;
|
||||
const videoUrl = status.result?.video_url;
|
||||
if (!videoUrl) {
|
||||
console.error("[combineFinalVideo] No video URL in result:", status.result);
|
||||
throw new Error("Final video URL not found in result. Please contact support.");
|
||||
}
|
||||
console.log("[combineFinalVideo] Success! Video URL:", videoUrl);
|
||||
setFinalVideoUrl(videoUrl);
|
||||
|
||||
// Save final video URL to project for persistence across reloads
|
||||
try {
|
||||
await podcastApi.saveProject(projectId, { final_video_url: videoUrl });
|
||||
console.log("[combineFinalVideo] Saved final video URL to project");
|
||||
} catch (error) {
|
||||
console.warn("[combineFinalVideo] Failed to save final video URL to project:", error);
|
||||
// Don't fail the operation if project save fails - video is still available
|
||||
}
|
||||
} else if (status.status === "failed") {
|
||||
const errorMsg = status.error || status.message || "Video combination failed";
|
||||
console.error("[combineFinalVideo] Task failed:", errorMsg);
|
||||
throw new Error(`Video combination failed: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (pollCount >= maxPolls) {
|
||||
throw new Error("Video combination timed out after 10 minutes. The video may still be processing. Please check back in a few minutes or try again.");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[combineFinalVideo] Error:", error);
|
||||
|
||||
// Extract detailed error message
|
||||
let message = "Failed to combine videos";
|
||||
|
||||
if (error?.response?.data?.detail) {
|
||||
// Backend error with detail
|
||||
message = error.response.data.detail;
|
||||
} else if (error?.message) {
|
||||
// Standard error message
|
||||
message = error.message;
|
||||
} else if (typeof error === "string") {
|
||||
message = error;
|
||||
}
|
||||
|
||||
console.error("[combineFinalVideo] Displaying error to user:", message);
|
||||
onError(message);
|
||||
} finally {
|
||||
setCombiningVideos(false);
|
||||
setCombiningProgress(null);
|
||||
}
|
||||
}, [script.scenes, jobs, projectId, onError]);
|
||||
|
||||
return {
|
||||
rendering,
|
||||
generatingImage,
|
||||
combiningAudio,
|
||||
combinedAudioResult,
|
||||
combiningVideos,
|
||||
combiningProgress,
|
||||
finalVideoUrl,
|
||||
isBusy: Boolean(rendering),
|
||||
runRender,
|
||||
runImageGeneration,
|
||||
runVideoRender,
|
||||
combineAudio,
|
||||
combineFinalVideo,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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. 64–128 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -120,6 +120,7 @@ export type CreateProjectPayload = {
|
||||
knobs: Knobs;
|
||||
budgetCap: number;
|
||||
files: { voiceFile?: File | null; avatarFile?: File | null };
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
|
||||
export type CreateProjectResult = {
|
||||
@@ -141,6 +142,13 @@ export type RenderJobResult = {
|
||||
videoFilename?: string;
|
||||
};
|
||||
|
||||
export interface VideoGenerationSettings {
|
||||
prompt: string;
|
||||
resolution: "480p" | "720p";
|
||||
seed?: number | null;
|
||||
maskImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export type TaskStatus = {
|
||||
task_id: string;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Button, Tooltip, CircularProgress, alpha } from "@mui/material";
|
||||
import { Button, Tooltip, CircularProgress, alpha, SxProps, Theme } from "@mui/material";
|
||||
|
||||
interface SecondaryButtonProps {
|
||||
children: React.ReactNode;
|
||||
@@ -9,6 +9,7 @@ interface SecondaryButtonProps {
|
||||
startIcon?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
ariaLabel?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
|
||||
@@ -19,6 +20,7 @@ export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
|
||||
startIcon,
|
||||
tooltip,
|
||||
ariaLabel,
|
||||
sx,
|
||||
}) => {
|
||||
const button = (
|
||||
<Button
|
||||
@@ -27,17 +29,20 @@ export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
|
||||
disabled={disabled || loading}
|
||||
startIcon={loading ? <CircularProgress size={16} /> : startIcon}
|
||||
aria-label={ariaLabel}
|
||||
sx={{
|
||||
borderColor: "rgba(255,255,255,0.2)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
textTransform: "none",
|
||||
px: 2.5,
|
||||
py: 0.75,
|
||||
"&:hover": {
|
||||
borderColor: "rgba(255,255,255,0.4)",
|
||||
background: alpha("#fff", 0.05),
|
||||
sx={[
|
||||
{
|
||||
borderColor: "rgba(255,255,255,0.2)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
textTransform: "none",
|
||||
px: 2.5,
|
||||
py: 0.75,
|
||||
"&:hover": {
|
||||
borderColor: "rgba(255,255,255,0.4)",
|
||||
background: alpha("#fff", 0.05),
|
||||
},
|
||||
},
|
||||
}}
|
||||
...(Array.isArray(sx) ? sx : sx ? [sx] : []),
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user