diff --git a/backend/api/podcast/handlers/audio.py b/backend/api/podcast/handlers/audio.py index 18fe565e..bead13d6 100644 --- a/backend/api/podcast/handlers/audio.py +++ b/backend/api/podcast/handlers/audio.py @@ -391,9 +391,9 @@ async def serve_podcast_audio( raise HTTPException(status_code=400, detail="Invalid filename") user_id = require_authenticated_user(current_user) - logger.warning(f"[Podcast] serve_podcast_audio called: user_id={user_id}, filename={filename}") + logger.debug(f"[Podcast] serve_podcast_audio called: user_id={user_id}, filename={filename}") audio_path = _resolve_podcast_media_file(filename, "audio", user_id) - logger.warning(f"[Podcast] Resolved audio path: {audio_path}") + logger.debug(f"[Podcast] Resolved audio path: {audio_path}") return FileResponse(audio_path, media_type="audio/mpeg") diff --git a/backend/api/podcast/handlers/avatar.py b/backend/api/podcast/handlers/avatar.py index beef5daf..bc88fe7c 100644 --- a/backend/api/podcast/handlers/avatar.py +++ b/backend/api/podcast/handlers/avatar.py @@ -114,6 +114,9 @@ async def make_avatar_presentable( Transform an uploaded avatar image into a podcast-appropriate presenter. Uses AI image editing to convert the uploaded photo into a professional podcast presenter. """ + # CRITICAL: Log at the very start before any logic + logger.info(f"[Podcast] ===== MAKE PRESENTABLE ENDPOINT START =====") + user_id = require_authenticated_user(current_user) logger.info(f"[Podcast] Make presentable request received - user_id={user_id}, avatar_url={avatar_url}, project_id={project_id}") diff --git a/backend/api/podcast/handlers/script.py b/backend/api/podcast/handlers/script.py index ce24eded..7bf79152 100644 --- a/backend/api/podcast/handlers/script.py +++ b/backend/api/podcast/handlers/script.py @@ -178,25 +178,83 @@ COST OPTIMIZATION: scenes_data = data.get("scenes") or [] if not isinstance(scenes_data, list): raise HTTPException(status_code=500, detail="LLM response missing scenes array") + + if len(scenes_data) == 0: + logger.warning("[ScriptGen] LLM returned empty scenes array") + raise HTTPException(status_code=500, detail="LLM returned no scenes - please try again") + + logger.warning(f"[ScriptGen] Processing {len(scenes_data)} scenes from LLM response") valid_emotions = {"neutral", "happy", "excited", "serious", "curious", "confident"} # Normalize scenes scenes: list[PodcastScene] = [] + total_lines_input = 0 + total_lines_output = 0 + dropped_empty_lines = 0 + for idx, scene in enumerate(scenes_data): + if not isinstance(scene, dict): + logger.warning(f"[ScriptGen] Scene {idx} is not a dict, skipping") + continue + title = scene.get("title") or f"Scene {idx + 1}" duration = int(scene.get("duration") or max(30, (request.duration_minutes * 60) // max(1, len(scenes_data)))) emotion = scene.get("emotion") or "neutral" if emotion not in valid_emotions: + logger.warning(f"[ScriptGen] Invalid emotion '{emotion}' in scene {idx}, defaulting to 'neutral'") emotion = "neutral" lines_raw = scene.get("lines") or [] + total_lines_input += len(lines_raw) lines: list[PodcastSceneLine] = [] - for line in lines_raw: + + for line_idx, line in enumerate(lines_raw): + if not isinstance(line, dict): + logger.warning(f"[ScriptGen] Line {line_idx} in scene {idx} is not a dict, skipping") + continue + speaker = line.get("speaker") or ("Host" if len(lines) % request.speakers == 0 else "Guest") text = line.get("text") or "" - emphasis = line.get("emphasis", False) + + # Handle emphasis - convert various values to boolean + emphasis_raw = line.get("emphasis", False) + if isinstance(emphasis_raw, bool): + emphasis = emphasis_raw + elif isinstance(emphasis_raw, str): + emphasis = emphasis_raw.lower() in ("true", "yes", "1") + if emphasis_raw.lower() not in ("true", "false", "yes", "no", "1", "0"): + logger.debug(f"[ScriptGen] Unusual emphasis value '{emphasis_raw}' converted to {emphasis}") + else: + emphasis = bool(emphasis_raw) + + # Generate line ID if not provided + line_id = line.get("id") or f"line-{idx + 1}-{line_idx + 1}" + + # Get used fact IDs if provided + used_fact_ids = line.get("usedFactIds") or line.get("used_fact_ids") or None + if text: - lines.append(PodcastSceneLine(speaker=speaker, text=text, emphasis=emphasis)) + lines.append(PodcastSceneLine( + speaker=speaker, + text=text, + emphasis=emphasis, + id=line_id, + usedFactIds=used_fact_ids + )) + total_lines_output += 1 + else: + dropped_empty_lines += 1 + logger.debug(f"[ScriptGen] Dropped empty line {line_idx} in scene {idx}") + + # Log scene status + if scenes_data and isinstance(scene, dict): + image_url_raw = scene.get("imageUrl") or scene.get("image_url") + audio_url_raw = scene.get("audioUrl") or scene.get("audio_url") + if image_url_raw: + logger.warning(f"[ScriptGen] Scene {idx} has imageUrl - will be reset to None") + if audio_url_raw: + logger.warning(f"[ScriptGen] Scene {idx} has audioUrl - will be reset to None") + scenes.append( PodcastScene( id=scene.get("id") or f"scene-{idx + 1}", @@ -205,8 +263,16 @@ COST OPTIMIZATION: lines=lines, approved=False, emotion=emotion, + imageUrl=None, # Will be generated later + audioUrl=None, # Will be generated later + imagePrompt=None, # Will be generated during image generation ) ) + + # Summary logging + logger.warning(f"[ScriptGen] Script generated: {len(scenes)} scenes, {total_lines_output}/{total_lines_input} lines") + if dropped_empty_lines > 0: + logger.warning(f"[ScriptGen] Dropped {dropped_empty_lines} empty lines") return PodcastScriptResponse(scenes=scenes) diff --git a/backend/api/podcast/models.py b/backend/api/podcast/models.py index 14673f94..5f176c47 100644 --- a/backend/api/podcast/models.py +++ b/backend/api/podcast/models.py @@ -101,6 +101,8 @@ class PodcastSceneLine(BaseModel): speaker: str text: str emphasis: Optional[bool] = False + id: Optional[str] = None # Optional line ID for frontend tracking + usedFactIds: Optional[List[str]] = None # Facts referenced in this line class PodcastScene(BaseModel): @@ -111,6 +113,8 @@ class PodcastScene(BaseModel): approved: bool = False emotion: Optional[str] = None imageUrl: Optional[str] = None # Generated image URL for video generation + audioUrl: Optional[str] = None # Generated audio URL for this scene + imagePrompt: Optional[str] = None # Original image generation prompt for video context class PodcastExaConfig(BaseModel): diff --git a/backend/api/story_writer/utils/auth.py b/backend/api/story_writer/utils/auth.py index 31b4f57f..f0f92795 100644 --- a/backend/api/story_writer/utils/auth.py +++ b/backend/api/story_writer/utils/auth.py @@ -8,9 +8,14 @@ def require_authenticated_user(current_user: Dict[str, Any] | None) -> str: Validates the current user dictionary provided by Clerk middleware and returns the normalized user_id. Raises HTTP 401 if authentication fails. """ - if not current_user or not isinstance(current_user, dict): + # Guard against dependency injection issues where Depends object might be passed + if current_user is None or not isinstance(current_user, dict): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") - + + # Additional check: ensure it's actually a dict and not a Depends object or other type + if not hasattr(current_user, 'get') or not callable(getattr(current_user, 'get')): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication context") + user_id = str(current_user.get("id", "")).strip() if not user_id: raise HTTPException( diff --git a/backend/services/llm_providers/main_image_editing.py b/backend/services/llm_providers/main_image_editing.py index f3822c1c..505f365f 100644 --- a/backend/services/llm_providers/main_image_editing.py +++ b/backend/services/llm_providers/main_image_editing.py @@ -106,7 +106,7 @@ def edit_image( skip_validation = os.getenv("ALWRITY_SKIP_IMAGE_EDITING_VALIDATION", "false").lower() in ("true", "1", "yes") if user_id and not skip_validation: - from services.database import get_db + from services.database import get_session_for_user from services.subscription import PricingService from services.subscription.preflight_validator import validate_image_editing_operations from fastapi import HTTPException @@ -115,17 +115,18 @@ def edit_image( db = None try: - # Properly handle the generator - db_gen = get_db() - db = next(db_gen) - - pricing_service = PricingService(db) - # Raises HTTPException immediately if validation fails - frontend gets immediate response - validate_image_editing_operations( - pricing_service=pricing_service, - user_id=user_id - ) - logger.info(f"[Image Editing] ✅ Pre-flight validation passed for user_id={user_id} - proceeding with image editing") + # Use get_session_for_user instead of get_db() since we're outside FastAPI DI + db = get_session_for_user(user_id) + if not db: + logger.warning(f"[Image Editing] ⚠️ Could not get DB session for user {user_id} - skipping validation") + else: + pricing_service = PricingService(db) + # Raises HTTPException immediately if validation fails - frontend gets immediate response + validate_image_editing_operations( + pricing_service=pricing_service, + user_id=user_id + ) + logger.info(f"[Image Editing] ✅ Pre-flight validation passed for user_id={user_id} - proceeding with image editing") except HTTPException as http_ex: # Re-raise immediately - don't proceed with API call logger.error(f"[Image Editing] ❌ Pre-flight validation failed for user_id={user_id} - blocking API call: {http_ex.detail}") diff --git a/frontend/src/components/OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder.tsx b/frontend/src/components/OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder.tsx index 2836c3eb..c1185c8a 100644 --- a/frontend/src/components/OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder.tsx +++ b/frontend/src/components/OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useRef, useState, useEffect } from 'react'; -import { Box, Typography, Paper, Stack, Button, Alert, TextField, CircularProgress, Slider, FormControlLabel, Checkbox, MenuItem, Tooltip, Chip, Divider, Grid, IconButton, Modal, Fade, Backdrop } from '@mui/material'; +import { Box, Typography, Paper, Stack, Button, Alert, TextField, CircularProgress, Slider, FormControlLabel, Checkbox, MenuItem, Tooltip, Chip, Divider, Grid, IconButton, Modal, Fade, Backdrop, LinearProgress } from '@mui/material'; import { keyframes } from '@mui/system'; -import { Mic, GraphicEq, Timer, CloudUpload, Stop, PlayArrow, InfoOutlined, TextFields, HelpOutline, AutoAwesome, Campaign, MicNone, Podcasts, RestartAlt, Undo } from '@mui/icons-material'; +import { Mic, GraphicEq, Timer, CloudUpload, Stop, PlayArrow, InfoOutlined, TextFields, HelpOutline, AutoAwesome, Campaign, MicNone, Podcasts, RestartAlt, Undo, Headphones, Article, VideoLibrary, TrendingUp, CheckCircle, RecordVoiceOver } from '@mui/icons-material'; import { createVoiceClone, createVoiceDesign, getLatestVoiceClone, setBrandVoice } from '../../../../api/brandAssets'; import { OperationButton } from '../../../shared/OperationButton'; @@ -11,6 +11,38 @@ const pulse = keyframes` 100% { transform: scale(1); } `; +// Sequential educational messages - displayed one after another during cloning +const VOICE_CLONE_PROGRESS_MESSAGES = [ + { title: "Audio Analysis", message: "Extracting audio features from your sample recording..." }, + { title: "Voice Fingerprint", message: "Creating a unique voice fingerprint with 100+ characteristics..." }, + { title: "Neural Training", message: "Training neural networks to understand your voice patterns..." }, + { title: "Prosody Mapping", message: "Mapping rhythm, stress, and intonation for natural speech..." }, + { title: "Voice Synthesis", message: "Building the text-to-speech engine with your voice model..." }, + { title: "Quality Assurance", message: "Validating audio quality and natural voice characteristics..." }, + { title: "Final Touches", message: "Optimizing for clarity and preparing your voice clone..." }, +]; + +const VOICE_USE_CASES = [ + { icon: , title: "Podcasts", description: "Episode intros, narration, and voice-overs" }, + { icon:
, title: "Blog to Audio", description: "Convert articles into engaging audio" }, + { icon: , title: "YouTube Videos", description: "Video voice-overs and tutorials" }, + { icon: , title: "Audio Content", description: "Audiobooks, courses, and guides" }, +]; + +const BRAND_VOICE_BENEFITS = [ + { icon: , title: "Brand Consistency", description: "Same voice across all content channels" }, + { icon: , title: "Time Efficient", description: "Hours of audio from minutes of recording" }, + { icon: , title: "Professional Quality", description: "Studio-quality output without studio costs" }, + { icon: , title: "Instant Generation", description: "Generate speech from text instantly" }, +]; + +const WHY_BRAND_VOICE_MATTERS = [ + "Studies show consistent audio branding increases brand recognition by 80%", + "Voice cloning saves an average of 15+ hours per month vs traditional recording", + "Professional voice actors cost $200-500/hour – your clone is always available", + "Consistent voice builds trust and authority with your audience", +]; + export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?: () => void }> = ({ domainName, onVoiceSet }) => { const [recording, setRecording] = useState(false); const [recordSeconds, setRecordSeconds] = useState(0); @@ -31,8 +63,9 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet? const [voiceDescription, setVoiceDescription] = useState(''); // Debounce text inputs for token calculation to prevent button flickering - const [debouncedPreviewText, setDebouncedPreviewText] = useState(previewText); - const [debouncedVoiceDescription, setDebouncedVoiceDescription] = useState(voiceDescription); + // Initialize with the actual default values, not the state variables (to avoid closure issues) + const [debouncedPreviewText, setDebouncedPreviewText] = useState('Hello! Welcome to Alwrity! This is a preview of your cloned voice. I hope you enjoy it!'); + const [debouncedVoiceDescription, setDebouncedVoiceDescription] = useState(''); useEffect(() => { const handler = setTimeout(() => { @@ -50,6 +83,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet? const [cloning, setCloning] = useState(false); const [saving, setSaving] = useState(false); + const [progressMessageIndex, setProgressMessageIndex] = useState(0); const STORAGE_KEY = 'voice_clone_result_url'; const STORAGE_BACKUP_KEY = 'voice_clone_result_url_backup'; @@ -179,6 +213,23 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet? } }, [success, error]); + // Cycle progress messages during cloning - sequential, not repeating + useEffect(() => { + if (!cloning) { + setProgressMessageIndex(0); + return; + } + const interval = setInterval(() => { + setProgressMessageIndex((prev) => { + if (prev < VOICE_CLONE_PROGRESS_MESSAGES.length - 1) { + return prev + 1; + } + return prev; // Stay at last message + }); + }, 2500); + return () => clearInterval(interval); + }, [cloning]); + const handleSetAsBrandVoice = async () => { if (!resultAudioUrl) return; setSaving(true); @@ -1183,6 +1234,165 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet? + + {/* Voice Cloning Progress Modal */} + + + + + {/* Progress Header */} + + + + + + + + + {VOICE_CLONE_PROGRESS_MESSAGES[Math.min(progressMessageIndex, VOICE_CLONE_PROGRESS_MESSAGES.length - 1)].title} + + + + {/* Sequential Progress Steps */} + + + {VOICE_CLONE_PROGRESS_MESSAGES.slice(0, progressMessageIndex + 1).map((msg, idx) => { + const isCompleted = idx < progressMessageIndex; + const isCurrent = idx === progressMessageIndex; + return ( + + + {isCompleted ? ( + + ) : isCurrent ? ( + + ) : ( + + )} + + + + {msg.title} + + + + ); + })} + + + + + + + + {/* Use Cases Section */} + + + Where You'll Use Your Voice + + + {VOICE_USE_CASES.map((useCase, idx) => ( + + + {useCase.icon} + + {useCase.title} + + + {useCase.description} + + + + ))} + + + + + + {/* Benefits Section */} + + + Why Brand Voice Matters + + + {BRAND_VOICE_BENEFITS.map((benefit, idx) => ( + + {benefit.icon} + + + {benefit.title} + + + {benefit.description} + + + + ))} + + + + {/* Marketing Insights */} + + + 💡 Did You Know? + + + {WHY_BRAND_VOICE_MATTERS.slice(0, 2).map((fact, idx) => ( + + • {fact} + + ))} + + + + + This usually takes 10-30 seconds depending on your sample length + + + + + ); }; diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel.tsx b/frontend/src/components/PodcastMaker/AnalysisPanel.tsx index 474379bf..b9ac2cd9 100644 --- a/frontend/src/components/PodcastMaker/AnalysisPanel.tsx +++ b/frontend/src/components/PodcastMaker/AnalysisPanel.tsx @@ -1,11 +1,11 @@ import React, { useState, useEffect } from "react"; import { Stack, Box, Typography, Divider, Chip, alpha, Button } from "@mui/material"; -import { Psychology as PsychologyIcon, Insights as InsightsIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Add as AddIcon, EditNote as EditNoteIcon, Input as InputIcon, Groups as GroupsIcon, ListAlt as ListAltIcon, RecordVoiceOver as VoiceIcon, Lightbulb as TipsIcon, Quiz as TalkIcon } from "@mui/icons-material"; +import { Psychology as PsychologyIcon, Person as PersonIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Input as InputIcon, Groups as GroupsIcon, ListAlt as ListAltIcon, Lightbulb as TipsIcon, Article as ArticleIcon } from "@mui/icons-material"; import { PodcastAnalysis, PodcastEstimate } from "./types"; import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui"; import { Refresh as RefreshIcon } from "@mui/icons-material"; import { aiApiClient } from "../../api/client"; -import { InputsTab, AudienceTab, OutlineTab, TitlesTab, HookTab, TakeawaysTab, GuestTab, CTATab } from "./AnalysisPanel/tabs"; +import { InputsTab, AudienceTab, OutlineTab, EpisodeDetailsTab, TakeawaysTab, GuestTab } from "./AnalysisPanel/tabs"; interface AnalysisPanelProps { analysis: PodcastAnalysis | null; @@ -19,7 +19,7 @@ interface AnalysisPanelProps { onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void; } -type TabId = 'inputs' | 'audience' | 'content' | 'outline' | 'titles' | 'hook' | 'takeaways' | 'cta' | 'guest'; +type TabId = 'inputs' | 'audience' | 'outline' | 'details' | 'takeaways' | 'guest'; interface TabConfig { id: TabId; @@ -76,33 +76,42 @@ export const AnalysisPanel: React.FC = ({ const tabs: TabConfig[] = [ { id: 'inputs', label: 'Your Inputs', icon: }, - { id: 'audience', label: 'Audience', icon: }, - { id: 'content', label: 'Content', icon: }, + { id: 'audience', label: 'Audience & Keywords', icon: }, { id: 'outline', label: 'Outline', icon: }, - { id: 'titles', label: 'Titles', icon: }, - { id: 'hook', label: 'Hook', icon: }, + { id: 'details', label: 'Titles, Hook & CTA', icon: }, { id: 'takeaways', label: 'Takeaways', icon: }, - { id: 'guest', label: 'Guest', icon: }, - { id: 'cta', label: 'CTA', icon: }, + { id: 'guest', label: 'Guest Talking Points', icon: }, ]; const tabButtonStyles = (isActive: boolean) => ({ background: isActive ? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)" - : "transparent", - color: isActive ? "#fff" : "#64748b", - border: isActive ? "none" : "1px solid rgba(0,0,0,0.1)", - borderRadius: 2, - px: 2, - py: 1, - fontSize: "0.75rem", + : "#f8fafc", + color: isActive ? "#fff" : "#475569", + border: isActive + ? "none" + : "1px solid #e2e8f0", + borderRadius: 2.5, + px: 2.5, + py: 1.25, + fontSize: "0.8rem", fontWeight: 600, textTransform: "none" as const, - transition: "all 0.2s ease", + transition: "all 0.25s ease", + boxShadow: isActive + ? "0 4px 12px rgba(102, 126, 234, 0.3)" + : "0 1px 2px rgba(0, 0, 0, 0.05)", "&:hover": { background: isActive ? "linear-gradient(135deg, #764ba2 0%, #667eea 100%)" - : "rgba(102,126,234,0.08)", + : "#e2e8f0", + transform: isActive ? "translateY(-1px)" : "none", + boxShadow: isActive + ? "0 6px 16px rgba(102, 126, 234, 0.35)" + : "0 2px 4px rgba(0, 0, 0, 0.08)", + }, + "&:active": { + transform: "translateY(0)", }, }); @@ -115,7 +124,6 @@ export const AnalysisPanel: React.FC = ({ const handleSave = () => { if (editedAnalysis && onUpdateAnalysis) { - console.log('[AnalysisPanel] Saving updated analysis:', editedAnalysis); onUpdateAnalysis(JSON.parse(JSON.stringify(editedAnalysis))); } setIsEditing(false); @@ -254,8 +262,6 @@ export const AnalysisPanel: React.FC = ({ if (!analysis) return null; const currentAnalysis = isEditing && editedAnalysis ? editedAnalysis : analysis; - console.log('[AnalysisPanel] Rendering:', { isEditing, hasEditedAnalysis: !!editedAnalysis }); - return ( = ({ /> )} - {activeTab === 'titles' && ( - = ({ /> )} - {activeTab === 'hook' && ( - - )} - {activeTab === 'takeaways' && ( )} @@ -439,10 +441,6 @@ export const AnalysisPanel: React.FC = ({ {activeTab === 'guest' && ( )} - - {activeTab === 'cta' && ( - - )} diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/EpisodeDetailsTab.tsx b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/EpisodeDetailsTab.tsx new file mode 100644 index 00000000..c3acc58b --- /dev/null +++ b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/EpisodeDetailsTab.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import { Stack, Box, Typography, Chip, TextField, IconButton, Paper, Divider } from "@mui/material"; +import { EditNote as EditNoteIcon, Add as AddIcon, AutoAwesome as AutoAwesomeIcon, CallToAction as CTAIcon, Edit as EditIcon } from "@mui/icons-material"; +import { PodcastAnalysis } from "../../types"; +import { AnalysisTabContent } from "../AnalysisTabNav"; + +interface EpisodeDetailsTabProps { + analysis: PodcastAnalysis; + isEditing?: boolean; + handleRemoveTitle?: (title: string) => void; + handleAddTitle?: (title: string) => void; +} + +const inputStyles = { + '& .MuiInputBase-input': { color: '#111827 !important', fontWeight: 500 }, + '& .MuiInputLabel-root': { color: '#4b5563 !important' }, + '& .MuiOutlinedInput-root': { + bgcolor: '#ffffff !important', + '& fieldset': { borderColor: '#d1d5db !important' }, + '&:hover fieldset': { borderColor: '#4f46e5 !important' }, + '&.Mui-focused fieldset': { borderColor: '#4f46e5 !important' }, + }, +}; + +export const EpisodeDetailsTab: React.FC = ({ + analysis, + isEditing, + handleRemoveTitle, + handleAddTitle +}) => { + return ( + }> + + {/* Titles Section */} + + + + + Episode Titles + + + + {analysis.titleSuggestions?.map((title: string, idx: number) => ( + handleRemoveTitle?.(title) : undefined} + sx={{ + color: "#0f172a", + background: "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)", + border: "1px solid #e2e8f0", + maxWidth: "100%", + whiteSpace: "normal", + height: "auto", + py: 0.5, + "&:hover": { background: "#e2e8f0" }, + }} + /> + ))} + + {isEditing && ( + { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTitle?.((e.target as HTMLInputElement).value); + (e.target as HTMLInputElement).value = ''; + } + }} + InputProps={{ + endAdornment: ( + { + const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement); + handleAddTitle?.(input.value); + input.value = ''; + }}> + + + ) + }} + /> + )} + + + + + {/* Hook Section */} + + + + + Episode Hook + + + {analysis.episode_hook ? ( + + + "{analysis.episode_hook}" + + + ) : ( + + No episode hook generated yet. + + )} + + A 15-30 second opening hook to grab listener attention. + + + + + + {/* CTA Section */} + + + + + Listener CTA + + + {analysis.listener_cta ? ( + + + {analysis.listener_cta} + + + ) : ( + + No listener call-to-action generated yet. + + )} + + A call-to-action for listeners after the episode. + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/index.ts b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/index.ts index b9a1db7b..a27a4510 100644 --- a/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/index.ts +++ b/frontend/src/components/PodcastMaker/AnalysisPanel/tabs/index.ts @@ -7,3 +7,4 @@ export { TitlesTab } from "./TitlesTab"; export { OutlineTab } from "./OutlineTab"; export { AudienceTab } from "./AudienceTab"; export { InputsTab } from "./InputsTab"; +export { EpisodeDetailsTab } from "./EpisodeDetailsTab"; diff --git a/frontend/src/components/PodcastMaker/CreateModal.tsx b/frontend/src/components/PodcastMaker/CreateModal.tsx index bdf8b773..fd9ff7a3 100644 --- a/frontend/src/components/PodcastMaker/CreateModal.tsx +++ b/frontend/src/components/PodcastMaker/CreateModal.tsx @@ -501,19 +501,28 @@ export const CreateModal: React.FC = ({ onCreate, open, defaul 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); + if (result.avatar_url) { + // 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); + + // Revoke old blob URL if exists + if (avatarPreviewBlobUrl && avatarPreviewBlobUrl.startsWith("blob:")) { + URL.revokeObjectURL(avatarPreviewBlobUrl); + } + + setAvatarPreviewBlobUrl(blobUrl); + setAvatarPreview(result.avatar_url); + 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]); + }, [avatarUrl, makingPresentable, avatarPreviewBlobUrl]); return ( = ({ cameraSelfieOpen, setCameraSelfieOpen, }) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const isTablet = useMediaQuery(theme.breakpoints.down('md')); + + // Shorter tab labels for mobile + const tabLabels = isMobile + ? ["Brand", "Library", "Selfie", "Upload"] + : ["Use Brand Avatar", "Asset Library", "Take Selfie", "Upload Your Photo"]; + return ( = ({ value={avatarTab} onChange={setAvatarTab} variant="scrollable" - scrollButtons="auto" + scrollButtons={isMobile ? "auto" : false} + allowScrollButtonsMobile={isMobile} sx={{ mb: { xs: 2, sm: 3 }, - minHeight: { xs: 40, sm: 48 }, + minHeight: { xs: 36, sm: 48 }, + "& .MuiTabs-scrollButtons": { + color: "#64748b", + "&.Mui-disabled": { opacity: 0.3 }, + }, "& .MuiTabs-indicator": { display: "none", }, "& .MuiTabs-flexContainer": { - gap: { xs: 1, sm: 1.5 }, + gap: { xs: 0.5, sm: 1.5 }, }, "& .MuiTab-root": { textTransform: "none", - minHeight: { xs: 36, sm: 44 }, + minHeight: { xs: 32, sm: 44 }, fontWeight: 600, - fontSize: { xs: "0.75rem", sm: "0.875rem" }, - borderRadius: { xs: "8px", sm: "12px" }, - px: { xs: 1.5, sm: 2.5 }, + fontSize: { xs: "0.7rem", sm: "0.875rem" }, + borderRadius: { xs: "6px", sm: "12px" }, + px: { xs: 1, sm: 2.5 }, minWidth: { xs: "auto", sm: 0 }, color: "#64748b", border: "1.5px solid #e2e8f0", @@ -155,7 +169,7 @@ export const AvatarSelector: React.FC = ({ "&:hover": { borderColor: "#cbd5e1", backgroundColor: "#f8fafc", - transform: "translateY(-1px)", + transform: { xs: "none", sm: "translateY(-1px)" }, }, "&.Mui-selected": { color: "#ffffff", @@ -166,10 +180,9 @@ export const AvatarSelector: React.FC = ({ }, }} > - - - - + {tabLabels.map((label, index) => ( + + ))} {avatarTab === 0 && ( @@ -340,7 +353,7 @@ export const AvatarSelector: React.FC = ({ = ({ void; canSubmit: boolean; isSubmitting: boolean; + announcement?: string; } -// ============================================================================ -// Constants & Data -// ============================================================================ - const ANALYSIS_FEATURES = [ { icon: , text: "Target audience & content type analysis" }, { icon: , text: "5 high-impact keywords for discoverability" }, @@ -50,21 +60,85 @@ const ANALYSIS_FEATURES = [ { icon: , text: "Episode hook, key takeaways & listener CTA" }, ]; -const ANALYSIS_PROGRESS_STEPS = [ - "Analyzing target audience & content type", - "Generating keywords & title suggestions", - "Creating episode outlines", - "Generating research queries", - "Creating hook, takeaways & CTA", +// Sequential educational messages - displayed one after another +const EDUCATIONAL_MESSAGES = [ + { title: "Understanding Your Topic", message: "AI is analyzing your topic to identify the core theme, target audience, and content type..." }, + { title: "Audience Persona", message: "Building a detailed audience persona including demographics, interests, and pain points..." }, + { title: "Keyword Research", message: "Discovering high-impact keywords that will make your podcast discoverable in search..." }, + { title: "Title Optimization", message: "Generating catchy, SEO-friendly title suggestions that capture attention..." }, + { title: "Content Structure", message: "Creating a compelling narrative arc with hooks, segments, and call-to-actions..." }, + { title: "Research Queries", message: "Preparing intelligent research questions that will gather facts and insights..." }, + { title: "Episode Outline", message: "Drafting a structured outline with timestamps and key talking points..." }, + { title: "Quality Check", message: "Validating all generated content for accuracy and engagement potential..." }, +]; + +const PODCAST_CREATION_JOURNEY = [ + { + phase: "Analysis", + icon: , + color: "#a78bfa", + description: "AI learns your topic inside-out", + details: [ + "Identifies target audience demographics and interests", + "Extracts key themes and angles to explore", + "Generates research queries for deep diving", + ], + benefit: "Ensures your content resonates with the right people" + }, + { + phase: "Research", + icon: , + color: "#60a5fa", + description: "Deep dive into facts and insights", + details: [ + "Gathers statistics, quotes, and case studies", + "Finds trending topics and recent developments", + "Validates claims with credible sources", + ], + benefit: "Adds credibility and depth to your episode" + }, + { + phase: "Script", + icon: , + color: "#34d399", + description: "AI crafts your episode narrative", + details: [ + "Creates scene-by-scene breakdowns", + "Writes natural dialogue and transitions", + "Optimizes pacing for engagement", + ], + benefit: "Professional script without hours of writing" + }, + { + phase: "Render", + icon: , + color: "#f472b6", + description: "Bring it all together visually", + details: [ + "Combines voice clone with avatar", + "Adds visual effects and transitions", + "Exports studio-quality video", + ], + benefit: "Ready-to-publish podcast video" + }, +]; + +const MARKETING_INSIGHTS = [ + { icon: , title: "Brand Recognition", text: "Consistent podcasting builds brand authority and trust" }, + { icon: , title: "Audience Growth", text: "AI-optimized content attracts and retains listeners" }, + { icon: , title: "Thought Leadership", text: "Research-backed content positions you as an expert" }, + { icon: , title: "Marketing Funnel", text: "Podcasts drive traffic to your products and services" }, +]; + +const USE_CASES = [ + { icon: , title: "Regular Episodes", text: "Weekly/bi-weekly podcast episodes" }, + { icon: , title: "Content Repurposing", text: "Turn blogs and videos into podcasts" }, + { icon: , title: "Marketing Campaigns", text: "Launch promotions with audio content" }, ]; const INFO_BANNER_TEXT = "Podcast avatar Image is required. Brand avatar is default. You can choose from asset library or upload your picture. If not, AI Avatar will be generated automatically."; -// ============================================================================ -// Styles -// ============================================================================ - const styles = { dialog: { background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)", @@ -77,23 +151,16 @@ const styles = { borderRadius: 2, boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)", }, - progressDot: { - width: 6, - height: 6, - borderRadius: "50%", - bgcolor: "#a78bfa", - }, dialogContent: { color: "rgba(255,255,255,0.8)", minHeight: 200, - py: 3, + py: 2, + px: { xs: 2, sm: 3 }, + maxHeight: { xs: "80vh", sm: "70vh" }, + overflowY: "auto", }, }; -// ============================================================================ -// Sub-Components -// ============================================================================ - const InfoBanner: React.FC<{ showInfo: boolean; setShowInfo: (v: boolean) => void }> = ({ showInfo, setShowInfo, @@ -121,47 +188,176 @@ const ShowTipsLink: React.FC<{ onClick: () => void }> = ({ onClick }) => ( ); -const AnalysisProgressView: React.FC = () => ( - - - - - - - +const AnalysisProgressView: React.FC<{ currentMessage?: string; progressIndex: number }> = ({ currentMessage, progressIndex }) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const clampedIndex = Math.min(progressIndex, EDUCATIONAL_MESSAGES.length - 1); + + return ( + + {/* Current Status */} + + + + + + + - - Analyzing Your Podcast Idea - + + {EDUCATIONAL_MESSAGES[clampedIndex].title} + + + + {EDUCATIONAL_MESSAGES[clampedIndex].message} + - - - - - This may take a few moments... - - - {ANALYSIS_PROGRESS_STEPS.map((step, idx) => ( - - {step} + {currentMessage && currentMessage !== EDUCATIONAL_MESSAGES[clampedIndex].message && ( + + {currentMessage} - ))} - - - -); + )} -const WhatYoullGetView: React.FC = () => ( + + + + Step {clampedIndex + 1} of {EDUCATIONAL_MESSAGES.length} + + + + + + {/* Sequential Progress Steps */} + + + Analysis Progress + + + {EDUCATIONAL_MESSAGES.map((msg, idx) => { + const isCompleted = idx < clampedIndex; + const isCurrent = idx === clampedIndex; + return ( + + + {isCompleted ? ( + + ) : isCurrent ? ( + + ) : ( + + )} + + + + {msg.title} + + + + ); + })} + + + + + + {/* Journey Overview - Responsive */} + + + Your Podcast Journey + + + {PODCAST_CREATION_JOURNEY.map((phase, idx) => ( + + + + {React.cloneElement(phase.icon, { sx: { color: phase.color, fontSize: 16 } })} + + + + {phase.phase} + + + {phase.description} + + + ✓ {phase.benefit} + + + + + ))} + + + + {!isMobile && ( + <> + + + {/* Marketing Insights */} + + + Marketing Benefits + + + {MARKETING_INSIGHTS.map((insight, idx) => ( + + ))} + + + + )} + + ); +}; + +const WhatYoullGetView: React.FC<{ isMobile?: boolean }> = ({ isMobile }) => ( <> - + Click "Start Analysis" to begin AI-powered podcast planning. Here's what we'll generate for you: @@ -170,7 +366,7 @@ const WhatYoullGetView: React.FC = () => ( {feature.icon} ))} @@ -178,26 +374,34 @@ const WhatYoullGetView: React.FC = () => ( ); -// ============================================================================ -// Main Component -// ============================================================================ - -export const CreateActions: React.FC = ({ reset, submit, canSubmit, isSubmitting }) => { +export const CreateActions: React.FC = ({ reset, submit, canSubmit, isSubmitting, announcement }) => { const [showInfo, setShowInfo] = useState(true); const [showAnalysisModal, setShowAnalysisModal] = useState(false); const [analysisStarted, setAnalysisStarted] = useState(false); + const [progressIndex, setProgressIndex] = useState(0); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); useEffect(() => { const timer = setTimeout(() => setShowInfo(false), 8000); return () => clearTimeout(timer); }, []); - // Close modal when analysis completes + // Sequential progress - increment every few seconds useEffect(() => { - if (!isSubmitting && analysisStarted) { - setShowAnalysisModal(false); - setAnalysisStarted(false); + if (!isSubmitting || !analysisStarted) { + setProgressIndex(0); + return; } + const interval = setInterval(() => { + setProgressIndex((prev) => { + if (prev < EDUCATIONAL_MESSAGES.length - 1) { + return prev + 1; + } + return prev; + }); + }, 3000); + return () => clearInterval(interval); }, [isSubmitting, analysisStarted]); const handleSubmitClick = () => { @@ -206,11 +410,16 @@ export const CreateActions: React.FC = ({ reset, submit, can const handleStartAnalysis = () => { setAnalysisStarted(true); + setProgressIndex(0); submit(); }; const showProgressInModal = showAnalysisModal && (analysisStarted || isSubmitting); + const buttonText = canSubmit + ? "Continue to Research Topic" + : "Provide Podcast Inputs to Continue"; + return ( @@ -225,9 +434,9 @@ export const CreateActions: React.FC = ({ reset, submit, can disabled={!canSubmit || isSubmitting} loading={isSubmitting} startIcon={} - tooltip={!canSubmit ? "Complete all steps: 1) Enter topic/URL, 2) Configure duration & speakers, 3) Add avatar, 4) Select voice" : "We'll start AI analysis after this click"} + tooltip={!canSubmit ? "Complete all 4 steps: 1) Enter topic/URL, 2) Configure duration & speakers, 3) Add avatar, 4) Select voice" : "We'll start AI analysis after this click"} > - {isSubmitting ? "Analyzing..." : "Analyze & Continue"} + {isSubmitting ? "Analyzing..." : buttonText} @@ -236,13 +445,14 @@ export const CreateActions: React.FC = ({ reset, submit, can onClose={() => !isSubmitting && setShowAnalysisModal(false)} maxWidth="sm" fullWidth - PaperProps={{ sx: styles.dialog }} + fullScreen={isMobile} + PaperProps={{ sx: { ...styles.dialog, ...(isMobile ? { borderRadius: 0 } : {}) } }} > - + {isSubmitting ? ( - - Analyzing Your Podcast Idea + + Creating Your Podcast ) : ( @@ -252,11 +462,15 @@ export const CreateActions: React.FC = ({ reset, submit, can )} - - {showProgressInModal ? : } + + {showProgressInModal ? ( + + ) : ( + + )} - + {showProgressInModal ? null : ( <> setShowAnalysisModal(false)}>Cancel @@ -269,4 +483,4 @@ export const CreateActions: React.FC = ({ reset, submit, can ); -}; +}; \ No newline at end of file diff --git a/frontend/src/components/PodcastMaker/PodcastDashboard.tsx b/frontend/src/components/PodcastMaker/PodcastDashboard.tsx index 4152bf55..83bad5d5 100644 --- a/frontend/src/components/PodcastMaker/PodcastDashboard.tsx +++ b/frontend/src/components/PodcastMaker/PodcastDashboard.tsx @@ -26,7 +26,7 @@ const PodcastDashboard: React.FC = () => { useEffect(() => { try { const skip = shouldSkipOnboarding(); - console.log('PodcastDashboard entry: shouldSkipOnboarding =', skip); + // Skip onboarding in podcast-only mode } catch (e) { console.warn('PodcastDashboard entry: gating log error', e); } @@ -224,6 +224,7 @@ const PodcastDashboard: React.FC = () => { onCreate={workflow.handleCreate} defaultKnobs={DEFAULT_KNOBS} isSubmitting={workflow.isAnalyzing} + announcement={workflow.announcement} /> {}} /> diff --git a/frontend/src/components/PodcastMaker/PodcastDashboard/usePodcastWorkflow.ts b/frontend/src/components/PodcastMaker/PodcastDashboard/usePodcastWorkflow.ts index a0fe155c..5e6e3438 100644 --- a/frontend/src/components/PodcastMaker/PodcastDashboard/usePodcastWorkflow.ts +++ b/frontend/src/components/PodcastMaker/PodcastDashboard/usePodcastWorkflow.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from "react"; +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { podcastApi } from "../../../services/podcastApi"; import { usePreflightCheck } from "../../../hooks/usePreflightCheck"; import { useBudgetTracking } from "../../../hooks/useBudgetTracking"; @@ -344,8 +344,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow } }, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]); - // Add a ref to track if we're currently generating to prevent double calls - const isGeneratingRef = React.useRef(false); +// Add a ref to track if we're currently generating to prevent double calls + const isGeneratingRef = useRef(false); const handleGenerateScript = useCallback(async () => { // Guard against double calls @@ -360,8 +360,9 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow return; } - // Mark as generating immediately + // Mark as generating immediately (both ref and state) isGeneratingRef.current = true; + setIsGeneratingScript(true); setPreflightOperationName("Script Generation"); const preflightResult = await preflightCheck.check({ @@ -373,13 +374,13 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow if (!preflightResult.can_proceed) { isGeneratingRef.current = false; // Reset on preflight failure + setIsGeneratingScript(false); // Reset loading state on preflight failure return; } setScriptData(null); setShowRenderQueue(false); setShowScriptEditor(true); - setIsGeneratingScript(true); setAnnouncement("Generating script with AI... Creating scenes and dialogue based on your research..."); try { diff --git a/frontend/src/components/PodcastMaker/RobustCamera.tsx b/frontend/src/components/PodcastMaker/RobustCamera.tsx index 5983bd35..572fc49b 100644 --- a/frontend/src/components/PodcastMaker/RobustCamera.tsx +++ b/frontend/src/components/PodcastMaker/RobustCamera.tsx @@ -47,8 +47,6 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, // Cleanup function - stops all tracks and clears video const cleanupCamera = useCallback(() => { - console.log('[RobustCamera] Cleaning up camera'); - // Reset attachment tracking streamAttachedRef.current = false; @@ -62,7 +60,6 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, // Stop all tracks in the stream if (stream) { stream.getTracks().forEach(track => { - console.log('[RobustCamera] Stopping track:', track.kind, track.label); track.stop(); }); } @@ -80,28 +77,23 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, // Early exit conditions if (!video || !stream) { - console.log('[RobustCamera] Cannot attach - video:', !!video, 'stream:', !!stream); streamAttachedRef.current = false; return; } // Skip if already attached to this stream if (video.srcObject === stream && streamAttachedRef.current) { - console.log('[RobustCamera] Stream already attached to video'); return; } - console.log('[RobustCamera] Attaching stream to video element'); streamAttachedRef.current = true; // Set up event handlers const handleLoadedMetadata = () => { - console.log('[RobustCamera] Video metadata loaded, playing...'); if (!isMountedRef.current) return; video.play() .then(() => { - console.log('[RobustCamera] Video playing successfully'); if (isMountedRef.current) { setCameraReady(true); } @@ -130,7 +122,6 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, // Cleanup function - only remove listeners, don't detach stream return () => { - console.log('[RobustCamera] Cleaning up video event listeners'); video.removeEventListener('loadedmetadata', handleLoadedMetadata); video.removeEventListener('error', handleError); }; @@ -141,22 +132,18 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, let isCancelled = false; if (open) { - console.log('[RobustCamera] Dialog opened'); isMountedRef.current = true; const initCamera = async () => { // Prevent double initialization if (isInitializingRef.current) { - console.log('[RobustCamera] Already initializing, skipping'); return; } if (isCancelled) { - console.log('[RobustCamera] Cancelled before initialization'); return; } - console.log('[RobustCamera] Starting camera initialization'); isInitializingRef.current = true; setLoading(true); setError(null); @@ -164,7 +151,6 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, // Clean up any existing stream first if (stream) { - console.log('[RobustCamera] Cleaning up existing stream first'); stream.getTracks().forEach(track => track.stop()); setStream(null); } @@ -179,18 +165,15 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, audio: false, }; - console.log('[RobustCamera] Requesting camera with constraints:', constraints); const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); // Check if cancelled or unmounted after await if (isCancelled || !isMountedRef.current) { - console.log('[RobustCamera] Cancelled after stream obtained, stopping stream'); mediaStream.getTracks().forEach(track => track.stop()); isInitializingRef.current = false; return; } - console.log('[RobustCamera] Camera stream obtained:', mediaStream.id, 'Tracks:', mediaStream.getTracks().length); setStream(mediaStream); setLoading(false); @@ -228,13 +211,11 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, }, 100); return () => { - console.log('[RobustCamera] Cleanup from open effect'); isCancelled = true; clearTimeout(timer); }; } else { // Dialog closed - cleanup - console.log('[RobustCamera] Dialog closed, cleaning up'); cleanupCamera(); } }, [open]); // Only depend on open to prevent re-runs @@ -244,8 +225,6 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, let isCancelled = false; if (open && stream) { - console.log('[RobustCamera] Facing mode changed to', facingMode, ', reinitializing'); - const reinitCamera = async () => { cleanupCamera(); @@ -304,7 +283,6 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, // Cleanup on unmount useEffect(() => { return () => { - console.log('[RobustCamera] Component unmounting'); isMountedRef.current = false; cleanupCamera(); }; @@ -313,7 +291,6 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, // Capture photo const capturePhoto = useCallback(() => { if (!videoElementRef.current || !canvasRef.current || !cameraReady) { - console.log('[RobustCamera] Cannot capture: not ready'); return; } @@ -324,8 +301,6 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, canvas.width = video.videoWidth || 1280; canvas.height = video.videoHeight || 720; - console.log('[RobustCamera] Capturing photo:', canvas.width, 'x', canvas.height); - // Draw video frame to canvas const context = canvas.getContext('2d'); if (context) { @@ -339,7 +314,6 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, // Convert to data URL const imageDataUrl = canvas.toDataURL('image/jpeg', 0.9); - console.log('[RobustCamera] Photo captured'); onCapture(imageDataUrl); onClose(); @@ -348,7 +322,6 @@ export const RobustCamera: React.FC = ({ onCapture, onClose, // Flip camera const flipCamera = useCallback(() => { - console.log('[RobustCamera] Flipping camera'); setFacingMode(prev => prev === 'user' ? 'environment' : 'user'); }, []); diff --git a/frontend/src/utils/demoMode.ts b/frontend/src/utils/demoMode.ts index e725ff5c..cb4ac4c2 100644 --- a/frontend/src/utils/demoMode.ts +++ b/frontend/src/utils/demoMode.ts @@ -8,36 +8,46 @@ const PRIMARY_STORAGE_KEY = 'enabled_features'; const PRIMARY_ENV_KEY = 'REACT_APP_ENABLED_FEATURES'; +// Cache for enabled features to avoid repeated logging +let cachedFeatures: Set | null = null; + /** * Get enabled features from localStorage or environment. * Returns a Set of enabled feature names. */ export function getEnabledFeatures(): Set { + // Return cached value if available + if (cachedFeatures) { + return cachedFeatures; + } + // Check localStorage first const storageValue = localStorage.getItem(PRIMARY_STORAGE_KEY); if (storageValue) { - console.log('demoMode: Found in localStorage:', storageValue); const features = storageValue.toLowerCase().split(',').map(f => f.trim()); if (features.includes('all')) { - return new Set(['all']); + cachedFeatures = new Set(['all']); + return cachedFeatures; } - return new Set(features.filter(f => f)); + cachedFeatures = new Set(features.filter(f => f)); + return cachedFeatures; } // Check environment variable const envValue = process.env[PRIMARY_ENV_KEY]; - console.log('demoMode: ENV value for', PRIMARY_ENV_KEY, ':', envValue); if (envValue) { const features = envValue.toLowerCase().split(',').map(f => f.trim()); if (features.includes('all')) { - return new Set(['all']); + cachedFeatures = new Set(['all']); + return cachedFeatures; } - return new Set(features.filter(f => f)); + cachedFeatures = new Set(features.filter(f => f)); + return cachedFeatures; } // Default: all features enabled - console.log('demoMode: No env var, returning default "all"'); - return new Set(['all']); + cachedFeatures = new Set(['all']); + return cachedFeatures; } /**