Files
ALwrity/frontend/src/components/PodcastMaker/CreateModal.tsx
ajaysi 80838ed028 Fix: Implement isCancelled pattern and memoize callbacks to prevent camera unmounting
- Wrap all AvatarSelector callback handlers in useCallback in CreateModal.tsx
- Add isCancelled flag pattern to RobustCamera useEffect
- Inline camera initialization to avoid stale closure issues
- Add proper cleanup on component unmount
- Ensure camera stream is properly stopped if component unmounts during initialization
- Remove unused initializeCamera function
2026-04-07 11:39:07 +05:30

606 lines
20 KiB
TypeScript

import React, { useState, useEffect, useMemo } from "react";
import { Stack, Paper, Box } from "@mui/material";
import { CreateProjectPayload, Knobs } from "./types";
import { useSubscription } from "../../contexts/SubscriptionContext";
import { podcastApi } from "../../services/podcastApi";
import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl";
import { getLatestBrandAvatar } from "../../api/brandAssets";
import { VoiceSelector } from "../shared/VoiceSelector";
// Imported Components
import { TopicUrlInput, TOPIC_PLACEHOLDERS } from "./CreateStep/TopicUrlInput";
import { PodcastConfiguration } from "./CreateStep/PodcastConfiguration";
import { AvatarSelector } from "./CreateStep/AvatarSelector";
import { CreateActions } from "./CreateStep/CreateActions";
import { EnhancedTopicChoicesModal } from "./EnhancedTopicChoicesModal";
const ENHANCE_TOPIC_PROGRESS_MESSAGES = [
"Analyzing your topic idea...",
"Enhancing clarity and hook...",
"Aligning language for podcast listeners...",
];
interface CreateModalProps {
onCreate: (payload: CreateProjectPayload) => void;
open: boolean;
defaultKnobs: Knobs;
isSubmitting?: boolean;
announcement?: string;
}
export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false, announcement }) => {
const { subscription } = useSubscription();
const [topicInput, setTopicInput] = useState("");
const [showAIDetailsButton, setShowAIDetailsButton] = useState(false);
const [speakers, setSpeakers] = useState<number>(1);
const [duration, setDuration] = useState<number>(1);
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);
const [avatarPreviewBlobUrl, setAvatarPreviewBlobUrl] = useState<string | null>(null);
const [makingPresentable, setMakingPresentable] = useState(false);
const [enhancingTopic, setEnhancingTopic] = useState(false);
const [enhanceTopicProgressIndex, setEnhanceTopicProgressIndex] = useState(0);
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("Wise_Woman");
const [placeholderIndex, setPlaceholderIndex] = useState(0);
const [avatarTab, setAvatarTab] = useState(0);
const [loadingBrandAvatar, setLoadingBrandAvatar] = useState(false);
const [brandAvatarFromDb, setBrandAvatarFromDb] = useState<string | null>(null);
const [cameraSelfieOpen, setCameraSelfieOpen] = useState(false);
// Enhanced topic choices state
const [enhancedChoices, setEnhancedChoices] = useState<string[]>([]);
const [enhancedRationales, setEnhancedRationales] = useState<string[]>([]);
const [choicesModalOpen, setChoicesModalOpen] = useState(false);
const [editedChoices, setEditedChoices] = useState<string[]>([]);
// Rotate placeholder every 3 seconds
useEffect(() => {
if (!topicInput) {
const interval = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % TOPIC_PLACEHOLDERS.length);
}, 3000);
return () => clearInterval(interval);
}
}, [topicInput]);
// Fetch Brand Avatar on mount but don't select it
useEffect(() => {
const fetchBrandAvatar = async () => {
try {
setLoadingBrandAvatar(true);
const result = await getLatestBrandAvatar();
if (result.success && result.image_url) {
setBrandAvatarFromDb(result.image_url);
}
} catch (error) {
console.error("Failed to pre-fetch brand avatar:", error);
} finally {
setLoadingBrandAvatar(false);
}
};
fetchBrandAvatar();
}, []);
useEffect(() => {
if (!avatarPreview) {
setAvatarPreviewBlobUrl(null);
return;
}
if (avatarPreview.startsWith("data:") || avatarPreview.startsWith("blob:")) {
setAvatarPreviewBlobUrl(null);
return;
}
const isInternal =
avatarPreview.includes("/api/podcast/") ||
avatarPreview.includes("/api/youtube/") ||
avatarPreview.includes("/api/story/") ||
(avatarPreview.startsWith("/") && !avatarPreview.startsWith("//"));
if (!isInternal) {
setAvatarPreviewBlobUrl(null);
return;
}
let isMounted = true;
const currentPreview = avatarPreview;
const loadBlob = async () => {
try {
const blobUrl = await fetchMediaBlobUrl(currentPreview);
if (!isMounted || avatarPreview !== currentPreview) {
if (blobUrl && blobUrl.startsWith("blob:")) {
URL.revokeObjectURL(blobUrl);
}
return;
}
setAvatarPreviewBlobUrl((prev) => {
if (prev && prev !== blobUrl && prev.startsWith("blob:")) {
URL.revokeObjectURL(prev);
}
return blobUrl;
});
} catch {
if (isMounted && avatarPreview === currentPreview) {
setAvatarPreviewBlobUrl(null);
}
}
};
loadBlob();
return () => {
isMounted = false;
setAvatarPreviewBlobUrl((prev) => {
if (prev && prev.startsWith("blob:")) {
URL.revokeObjectURL(prev);
}
return null;
});
};
}, [avatarPreview]);
// Handle blob URL for the potential brand avatar preview (not selected yet)
const [brandAvatarBlobUrl, setBrandAvatarBlobUrl] = useState<string | null>(null);
useEffect(() => {
if (!brandAvatarFromDb) {
setBrandAvatarBlobUrl(null);
return;
}
let isMounted = true;
const loadBrandBlob = async () => {
try {
// Clear cache for this URL to ensure fresh data
if (brandAvatarFromDb) {
clearMediaCache(brandAvatarFromDb);
}
const blobUrl = await fetchMediaBlobUrl(brandAvatarFromDb);
if (isMounted) setBrandAvatarBlobUrl(blobUrl);
} catch (err) {
console.error("Failed to load brand avatar blob:", err);
}
};
loadBrandBlob();
return () => {
isMounted = false;
if (brandAvatarBlobUrl && brandAvatarBlobUrl.startsWith("blob:")) {
URL.revokeObjectURL(brandAvatarBlobUrl);
}
};
}, [brandAvatarFromDb]);
// Ensure duration and speakers are within limits
useEffect(() => {
if (duration > 10) {
setDuration(10);
}
if (speakers > 2) {
setSpeakers(2);
}
}, [duration, speakers]);
// URL detection helper
const detectUrl = (text: string): boolean => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return urlRegex.test(text);
};
const isUrl = useMemo(() => detectUrl(topicInput), [topicInput]);
const enhanceTopicMessage = enhancingTopic ? ENHANCE_TOPIC_PROGRESS_MESSAGES[enhanceTopicProgressIndex] : undefined;
useEffect(() => {
if (!enhancingTopic) {
setEnhanceTopicProgressIndex(0);
return;
}
const interval = setInterval(() => {
setEnhanceTopicProgressIndex((prev) => (prev + 1) % ENHANCE_TOPIC_PROGRESS_MESSAGES.length);
}, 1200);
return () => clearInterval(interval);
}, [enhancingTopic]);
// Handle AI Details button click
const handleAIDetailsClick = async () => {
if (!topicInput.trim() || enhancingTopic) return;
try {
setEnhancingTopic(true);
// We pass the current Bible context if we have it (unlikely here as it's generated in analysis)
// But the backend will generate it from onboarding data if missing
const result = await podcastApi.enhanceIdea({
idea: topicInput,
});
if (result.enhanced_ideas && result.enhanced_ideas.length === 3) {
setEnhancedChoices(result.enhanced_ideas);
setEnhancedRationales(result.rationales || []);
setEditedChoices(result.enhanced_ideas); // Initialize editable versions
setChoicesModalOpen(true);
}
} catch (error) {
console.error("Failed to enhance idea with AI:", error);
} finally {
setEnhancingTopic(false);
}
};
// Handle enhanced topic choice selection
const handleChoiceSelection = (selectedIndex: number, editedChoice: string) => {
const selectedTopic = editedChoice;
setTopicInput(selectedTopic);
setChoicesModalOpen(false);
// Reset choices state
setEnhancedChoices([]);
setEnhancedRationales([]);
setEditedChoices([]);
};
// Show AI details button when user starts typing (and it's not a URL)
useEffect(() => {
setShowAIDetailsButton(topicInput.trim().length > 0 && !isUrl);
}, [topicInput, isUrl]);
// Calculate estimated cost
const estimatedCost = useMemo(() => {
const chars = Math.max(1000, duration * 900); // ~900 chars per minute
const secs = duration * 60;
const ttsCost = (chars / 1000) * 0.05;
const avatarCost = speakers * 0.15;
const videoRate = knobs.bitrate === 'hd' ? 0.06 : 0.03;
const videoCost = secs * videoRate;
const researchCost = 0.3; // Fixed research cost
return {
ttsCost: +ttsCost.toFixed(2),
avatarCost: +avatarCost.toFixed(2),
videoCost: +videoCost.toFixed(2),
researchCost: +researchCost.toFixed(2),
total: +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2),
};
}, [duration, speakers, knobs.bitrate, knobs.scene_length_target]);
// Check if avatar is present (from any source: upload, brand avatar, or generated)
const hasAvatar = Boolean(
avatarFile || // User uploaded an image
avatarUrl || // Already processed avatar URL
avatarPreview || // Avatar preview available
brandAvatarFromDb || // Brand avatar from database
brandAvatarBlobUrl // Brand avatar blob URL
);
// Check if all required inputs are provided
const hasTopic = Boolean(topicInput.trim());
const hasVoice = Boolean(selectedVoiceId);
const hasDuration = Boolean(duration > 0 && duration <= 10);
const hasSpeakers = Boolean(speakers >= 1 && speakers <= 2);
const canSubmit = Boolean(hasTopic && hasAvatar && hasVoice && hasDuration && hasSpeakers);
const submit = async () => {
if (!canSubmit || isSubmitting) return;
// Determine if input is idea or URL
// For URL, we extract the first URL found or use the whole string if it's a direct URL
let finalIdea = "";
let finalUrl = "";
if (isUrl) {
// Simple extraction: if the input contains a URL, we treat the input as the URL (or extract it)
// For now, let's assume the user pasted a URL.
// If there's mixed text, we might want to just send the whole thing as 'url' if the backend handles extraction,
// or extract it here.
// The previous logic used specific 'url' state.
const urlMatch = topicInput.match(/(https?:\/\/[^\s]+)/);
if (urlMatch) {
finalUrl = urlMatch[0];
} else {
// Fallback
finalUrl = topicInput;
}
} else {
finalIdea = topicInput;
}
// 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
}
}
// Include selected voice in knobs
const finalKnobs = {
...knobs,
voice_id: selectedVoiceId,
};
onCreate({
ideaOrUrl: finalUrl || finalIdea,
speakers,
duration,
knobs: finalKnobs,
budgetCap,
files: { voiceFile, avatarFile },
avatarUrl: finalAvatarUrl,
});
};
const reset = () => {
setTopicInput("");
setSpeakers(1);
setDuration(1);
setBudgetCap(50);
setVoiceFile(null);
setAvatarFile(null);
setAvatarPreview(null);
setAvatarUrl(null);
setMakingPresentable(false);
setEnhancingTopic(false);
setEnhanceTopicProgressIndex(0);
setKnobs({ ...defaultKnobs });
setSelectedVoiceId("Wise_Woman");
setPlaceholderIndex(0);
};
const handleAvatarSelectFromLibrary = React.useCallback((url: string) => {
setAvatarFile(null);
setAvatarPreview(url);
setAvatarUrl(url);
}, []);
const handleAvatarChange = React.useCallback(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 handleCameraSelfie = React.useCallback(async (imageDataUrl: string) => {
try {
// Convert dataURL to File object
const response = await fetch(imageDataUrl);
const blob = await response.blob();
const file = new File([blob], 'selfie.jpg', { type: 'image/jpeg' });
// Set the file and preview
setAvatarFile(file);
setAvatarPreview(imageDataUrl);
// 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
}
// Close camera dialog
setCameraSelfieOpen(false);
} catch (error) {
console.error('Failed to process selfie:', error);
}
}, []);
const handleRemoveAvatar = React.useCallback(() => {
setAvatarFile(null);
setAvatarPreview(null);
setAvatarUrl(null);
if (avatarPreviewBlobUrl && avatarPreviewBlobUrl.startsWith("blob:")) {
URL.revokeObjectURL(avatarPreviewBlobUrl);
}
setAvatarPreviewBlobUrl(null);
setMakingPresentable(false);
}, [avatarPreviewBlobUrl]);
const handleUseBrandAvatar = React.useCallback(async () => {
if (brandAvatarFromDb) {
setAvatarFile(null);
setAvatarPreview(brandAvatarFromDb);
setAvatarUrl(brandAvatarFromDb);
// Ensure the blob URL is set for the preview logic
if (brandAvatarBlobUrl) {
setAvatarPreviewBlobUrl(brandAvatarBlobUrl);
}
return;
}
if (loadingBrandAvatar) return;
try {
setLoadingBrandAvatar(true);
const result = await getLatestBrandAvatar();
if (result.success && result.image_url) {
setAvatarFile(null);
setAvatarPreview(result.image_url);
setAvatarUrl(result.image_url);
setBrandAvatarFromDb(result.image_url);
} else {
console.error(result.error || result.message || "No brand avatar found");
}
} catch (error) {
console.error("Failed to load brand avatar:", error);
} finally {
setLoadingBrandAvatar(false);
}
}, [brandAvatarFromDb, brandAvatarBlobUrl, loadingBrandAvatar]);
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setAvatarTab(newValue);
if (newValue === 0) {
// Switch to brand avatar tab - it's already pre-fetched on mount
} else if (newValue === 1) {
// Asset Library tab - clear current selection so user must choose
setAvatarUrl(null);
setAvatarPreview(null);
setAvatarFile(null);
} else if (newValue === 2) {
// Upload tab - clear if no file uploaded yet to show dropzone clean state
if (!avatarFile) {
setAvatarUrl(null);
setAvatarPreview(null);
}
}
};
// Initialize with Brand Avatar removed - user must explicitly choose or it's AI generated
useEffect(() => {
// We used to auto-load here, but now we leave it empty to allow AI generation later
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleMakePresentable = React.useCallback(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);
}
}, [avatarUrl, makingPresentable]);
return (
<Paper
elevation={0}
sx={{
borderRadius: 3,
border: "1px solid rgba(15, 23, 42, 0.08)",
background: "#ffffff",
boxShadow: "0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.08)",
p: { xs: 3, md: 4.5 },
}}
>
<Stack spacing={3.5}>
<Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="stretch">
<Box sx={{ flex: 1 }}>
<TopicUrlInput
value={topicInput}
onChange={setTopicInput}
isUrl={isUrl}
showAIDetailsButton={showAIDetailsButton}
onAIDetailsClick={handleAIDetailsClick}
placeholderIndex={placeholderIndex}
loading={enhancingTopic}
loadingMessage={enhanceTopicMessage}
estimatedCost={estimatedCost}
duration={duration}
speakers={speakers}
knobs={knobs}
/>
</Box>
<Box sx={{ width: { xs: "100%", md: "320px" } }}>
<PodcastConfiguration
duration={duration}
setDuration={setDuration}
speakers={speakers}
setSpeakers={setSpeakers}
/>
</Box>
</Stack>
<AvatarSelector
avatarTab={avatarTab}
setAvatarTab={handleTabChange}
avatarFile={avatarFile}
avatarPreview={avatarPreview}
avatarUrl={avatarUrl}
loadingBrandAvatar={loadingBrandAvatar}
handleUseBrandAvatar={handleUseBrandAvatar}
handleAvatarSelectFromLibrary={handleAvatarSelectFromLibrary}
handleAvatarChange={handleAvatarChange}
handleCameraSelfie={handleCameraSelfie}
handleRemoveAvatar={handleRemoveAvatar}
handleMakePresentable={handleMakePresentable}
makingPresentable={makingPresentable}
avatarPreviewBlobUrl={avatarPreviewBlobUrl}
brandAvatarFromDb={brandAvatarFromDb}
brandAvatarBlobUrl={brandAvatarBlobUrl}
cameraSelfieOpen={cameraSelfieOpen}
setCameraSelfieOpen={setCameraSelfieOpen}
/>
<VoiceSelector
value={selectedVoiceId}
onChange={setSelectedVoiceId}
showVoiceClone={true}
/>
<CreateActions
reset={reset}
submit={submit}
canSubmit={canSubmit}
isSubmitting={isSubmitting}
announcement={announcement}
/>
{/* Enhanced Topic Choices Modal */}
<EnhancedTopicChoicesModal
open={choicesModalOpen}
onClose={() => setChoicesModalOpen(false)}
enhancedChoices={enhancedChoices}
enhancedRationales={enhancedRationales}
onSelectChoice={handleChoiceSelection}
loading={enhancingTopic}
/>
</Stack>
</Paper>
);
};