diff --git a/backend/api/podcast/cost_estimator.py b/backend/api/podcast/cost_estimator.py new file mode 100644 index 00000000..42afd952 --- /dev/null +++ b/backend/api/podcast/cost_estimator.py @@ -0,0 +1,133 @@ +""" +Podcast cost estimation helpers. + +Builds user-facing podcast estimates from the subscription pricing catalog +instead of hard-coded frontend heuristics. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional +from sqlalchemy.orm import Session + +from models.subscription_models import APIProvider +from services.subscription.pricing_service import PricingService + + +def _round_money(value: float) -> float: + return round(float(value), 4) + + +def _load_pricing( + pricing_service: PricingService, + provider: APIProvider, + preferred_model: str, +) -> Optional[Dict[str, Any]]: + pricing = pricing_service.get_pricing_for_provider_model(provider, preferred_model) + if pricing: + return pricing + # Fallback to provider default model row (if configured). + return pricing_service.get_pricing_for_provider_model(provider, "default") + + +def estimate_podcast_cost( + *, + db: Session, + duration_minutes: int, + speakers: int, + query_count: int, + include_avatar_phase: bool = True, +) -> Optional[Dict[str, Any]]: + """ + Compute a backend estimate for podcast creation. + + Returns None when pricing rows are unavailable so UI can display "Unavailable". + """ + pricing_service = PricingService(db) + + gemini_pricing = _load_pricing(pricing_service, APIProvider.GEMINI, "gemini-2.5-flash") + exa_pricing = _load_pricing(pricing_service, APIProvider.EXA, "exa-search") + audio_pricing = _load_pricing(pricing_service, APIProvider.AUDIO, "minimax/speech-02-hd") + video_pricing = _load_pricing(pricing_service, APIProvider.VIDEO, "default") + image_pricing = _load_pricing(pricing_service, APIProvider.STABILITY, "qwen-image") + + if not gemini_pricing: + return None + + minutes = max(1, int(duration_minutes or 1)) + speaker_count = max(1, int(speakers or 1)) + research_queries = max(1, int(query_count or 1)) + + # Phase-level usage assumptions (token/request proxies for pre-creation estimate). + analysis_input_tokens = 1800 + analysis_output_tokens = 1000 + research_synthesis_input_tokens = 2200 + research_synthesis_output_tokens = 900 + script_input_tokens = max(1800, minutes * 300) + script_output_tokens = max(2200, minutes * 700) + + # TTS token proxy: ~900 chars per minute per speaker. + estimated_tts_tokens = max(900, minutes * 900 * speaker_count) + + analysis_cost = ( + analysis_input_tokens * float(gemini_pricing.get("cost_per_input_token") or 0.0) + + analysis_output_tokens * float(gemini_pricing.get("cost_per_output_token") or 0.0) + + float(gemini_pricing.get("cost_per_request") or 0.0) + ) + research_llm_cost = ( + research_synthesis_input_tokens * float(gemini_pricing.get("cost_per_input_token") or 0.0) + + research_synthesis_output_tokens * float(gemini_pricing.get("cost_per_output_token") or 0.0) + + float(gemini_pricing.get("cost_per_request") or 0.0) + ) + script_cost = ( + script_input_tokens * float(gemini_pricing.get("cost_per_input_token") or 0.0) + + script_output_tokens * float(gemini_pricing.get("cost_per_output_token") or 0.0) + + float(gemini_pricing.get("cost_per_request") or 0.0) + ) + + research_search_cost = 0.0 + if exa_pricing: + research_search_cost = research_queries * float(exa_pricing.get("cost_per_request") or 0.0) + + tts_cost = 0.0 + if audio_pricing: + tts_cost = ( + estimated_tts_tokens * float(audio_pricing.get("cost_per_input_token") or 0.0) + + float(audio_pricing.get("cost_per_request") or 0.0) + ) + + # Assume one video render request per minute (upper-bound planning estimate). + video_cost = 0.0 + if video_pricing: + video_cost = minutes * float(video_pricing.get("cost_per_request") or 0.0) + + avatar_cost = 0.0 + if include_avatar_phase and image_pricing: + image_unit = float(image_pricing.get("cost_per_image") or image_pricing.get("cost_per_request") or 0.0) + avatar_cost = speaker_count * image_unit + + research_cost = research_search_cost + research_llm_cost + total = analysis_cost + research_cost + script_cost + tts_cost + video_cost + avatar_cost + + return { + "ttsCost": _round_money(tts_cost), + "avatarCost": _round_money(avatar_cost), + "videoCost": _round_money(video_cost), + "researchCost": _round_money(research_cost), + "analysisCost": _round_money(analysis_cost), + "scriptCost": _round_money(script_cost), + "total": _round_money(total), + "currency": "USD", + "source": "pricing_catalog", + "assumptions": { + "analysis_input_tokens": analysis_input_tokens, + "analysis_output_tokens": analysis_output_tokens, + "research_synthesis_input_tokens": research_synthesis_input_tokens, + "research_synthesis_output_tokens": research_synthesis_output_tokens, + "script_input_tokens": script_input_tokens, + "script_output_tokens": script_output_tokens, + "estimated_tts_tokens": estimated_tts_tokens, + "research_queries": research_queries, + "video_requests": minutes, + }, + } diff --git a/backend/api/podcast/handlers/analysis.py b/backend/api/podcast/handlers/analysis.py index c5d389ca..14b9b680 100644 --- a/backend/api/podcast/handlers/analysis.py +++ b/backend/api/podcast/handlers/analysis.py @@ -27,6 +27,7 @@ from ..models import ( PodcastEnhanceIdeaRequest, PodcastEnhanceIdeaResponse ) +from ..cost_estimator import estimate_podcast_cost # Check if running in podcast-only demo mode def _is_podcast_only_mode() -> bool: @@ -372,6 +373,13 @@ Requirements: listener_cta = data.get("listener_cta") or "" research_queries = data.get("research_queries") or [] exa_suggested_config = data.get("exa_suggested_config") or None + estimate = estimate_podcast_cost( + db=db, + duration_minutes=request.duration, + speakers=request.speakers, + query_count=len(research_queries) if isinstance(research_queries, list) else 0, + include_avatar_phase=podcast_mode != "audio_only", + ) return PodcastAnalyzeResponse( audience=audience, @@ -388,6 +396,7 @@ Requirements: bible=bible_obj.model_dump() if bible_obj else None, avatar_url=final_avatar_url, avatar_prompt=final_avatar_prompt, + estimate=estimate, ) @@ -492,4 +501,3 @@ Requirements: except Exception as exc: logger.error(f"[Regenerate Queries] Failed for user {user_id}: {exc}") raise HTTPException(status_code=500, detail=f"Regenerate queries failed: {exc}") - diff --git a/backend/api/podcast/handlers/research.py b/backend/api/podcast/handlers/research.py index 014e7747..3155702e 100644 --- a/backend/api/podcast/handlers/research.py +++ b/backend/api/podcast/handlers/research.py @@ -10,9 +10,11 @@ from types import SimpleNamespace import json import re from datetime import datetime, timezone +from sqlalchemy.orm import Session from middleware.auth_middleware import get_current_user from api.story_writer.utils.auth import require_authenticated_user +from services.database import get_db from services.blog_writer.research.exa_provider import ExaResearchProvider from services.llm_providers.main_text_generation import llm_text_gen from services.podcast_bible_service import PodcastBibleService @@ -20,6 +22,7 @@ from services.database import get_db from services.subscription import PricingService from models.subscription_models import APIProvider from loguru import logger +from ..cost_estimator import estimate_podcast_cost from ..models import ( PodcastExaResearchRequest, PodcastExaResearchResponse, @@ -126,6 +129,7 @@ def _build_research_cost_estimate( async def podcast_research_exa( request: PodcastExaResearchRequest, current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db), ): """ Run podcast research via Exa and then use LLM to extract deep insights. @@ -391,6 +395,20 @@ QUALITY STANDARDS: "credibility_score": src.get("credibility_score"), })) + duration_minutes = 10 + speakers = 1 + if request.analysis: + duration_minutes = int(request.analysis.get("duration", 10) or 10) + speakers = int(request.analysis.get("speakers", 1) or 1) + + estimate = estimate_podcast_cost( + db=db, + duration_minutes=duration_minutes, + speakers=speakers, + query_count=len(queries), + include_avatar_phase=True, + ) + return PodcastExaResearchResponse( sources=sources_payload, search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries, @@ -405,4 +423,5 @@ QUALITY STANDARDS: search_type=result.get("search_type") if isinstance(result, dict) else None, provider=result.get("provider", "exa") if isinstance(result, dict) else "exa", content=raw_content, + estimate=estimate, ) diff --git a/backend/api/podcast/models.py b/backend/api/podcast/models.py index 8f9723ca..2fb7349d 100644 --- a/backend/api/podcast/models.py +++ b/backend/api/podcast/models.py @@ -73,6 +73,7 @@ class PodcastAnalyzeResponse(BaseModel): bible: Optional[Dict[str, Any]] = None avatar_url: Optional[str] = None avatar_prompt: Optional[str] = None + estimate: Optional[Dict[str, Any]] = None class PodcastEnhanceIdeaRequest(BaseModel): @@ -205,6 +206,7 @@ class PodcastExaResearchResponse(BaseModel): mapped_angles: List[Dict[str, Any]] = [] # Content angles for the episode expert_quotes: List[Dict[str, Any]] = [] # Expert quotes from research listener_cta_suggestions: List[str] = [] # CTA suggestions + estimate: Optional[Dict[str, Any]] = None class PodcastScriptResponse(BaseModel): diff --git a/frontend/src/components/PodcastMaker/CreateModal.tsx b/frontend/src/components/PodcastMaker/CreateModal.tsx index 9e897dcd..ce259574 100644 --- a/frontend/src/components/PodcastMaker/CreateModal.tsx +++ b/frontend/src/components/PodcastMaker/CreateModal.tsx @@ -253,26 +253,6 @@ export const CreateModal: React.FC = ({ onCreate, open, defaul 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 @@ -560,7 +540,7 @@ export const CreateModal: React.FC = ({ onCreate, open, defaul placeholderIndex={placeholderIndex} loading={enhancingTopic} loadingMessage={enhanceTopicMessage} - estimatedCost={estimatedCost} + estimatedCost={null} duration={duration} speakers={speakers} knobs={knobs} diff --git a/frontend/src/components/PodcastMaker/CreateStep/TopicUrlInput.tsx b/frontend/src/components/PodcastMaker/CreateStep/TopicUrlInput.tsx index 06b4479a..546eca6d 100644 --- a/frontend/src/components/PodcastMaker/CreateStep/TopicUrlInput.tsx +++ b/frontend/src/components/PodcastMaker/CreateStep/TopicUrlInput.tsx @@ -27,7 +27,7 @@ interface TopicUrlInputProps { videoCost: number; researchCost: number; total: number; - }; + } | null; duration?: number; speakers?: number; knobs?: Knobs; @@ -115,9 +115,9 @@ export const TopicUrlInput: React.FC = ({ - {estimatedCost && ( - Estimated Cost Breakdown: @@ -135,43 +135,49 @@ export const TopicUrlInput: React.FC = ({ - } - arrow - placement="top" - componentsProps={{ - tooltip: { - sx: { - bgcolor: "#0f172a", - color: "#ffffff", - maxWidth: 280, - fontSize: "0.875rem", - p: 1.5, - boxShadow: "0 4px 12px rgba(0,0,0,0.15)", - }, + ) : ( + "Estimate unavailable until returned by the server." + ) + } + arrow + placement="top" + componentsProps={{ + tooltip: { + sx: { + bgcolor: "#0f172a", + color: "#ffffff", + maxWidth: 280, + fontSize: "0.875rem", + p: 1.5, + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", }, - arrow: { - sx: { - color: "#0f172a", - }, + }, + arrow: { + sx: { + color: "#0f172a", }, + }, + }} + > + } + label={estimatedCost ? `Est. $${estimatedCost.total}` : "Est. Unavailable"} + size="small" + sx={{ + background: estimatedCost + ? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)" + : "rgba(100, 116, 139, 0.12)", + color: estimatedCost ? "#059669" : "#475569", + fontWeight: 600, + border: estimatedCost + ? "1px solid rgba(16, 185, 129, 0.2)" + : "1px solid rgba(100, 116, 139, 0.25)", + fontSize: "0.75rem", + height: 26, + cursor: "help", }} - > - } - label={`Est. $${estimatedCost.total}`} - size="small" - sx={{ - background: "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)", - color: "#059669", - fontWeight: 600, - border: "1px solid rgba(16, 185, 129, 0.2)", - fontSize: "0.75rem", - height: 26, - cursor: "help", - }} - /> - - )} + /> + { return segments.slice(0, 5); }; -const estimateCosts = ({ - minutes, - scenes, - chars, - quality, - avatars, - queryCount = 3, - voiceId, -}: { - minutes: number; - scenes: number; - chars: number; - quality: string; - avatars: number; - queryCount?: number; - voiceId?: string; -}): PodcastEstimate => { - const secs = Math.max(60, minutes * 60); - const ttsCost = (chars / 1000) * 0.05; - const avatarCost = avatars * 0.15; - const videoRate = quality === "hd" ? 0.06 : 0.03; - const videoCost = secs * videoRate; - const researchCost = +(Math.max(1, queryCount) * 0.1).toFixed(2); - const total = +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2); - const isCustomVoice = Boolean(voiceId && !["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman", "Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man", "Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess", "Sweet_Girl_2", "Exuberant_Girl"].includes(voiceId)); - const voiceName = isCustomVoice ? "My Voice Clone" : (!voiceId ? "Wise Woman" : voiceId.replace(/_/g, " ")); +const toPodcastEstimate = (raw: any, voiceId?: string): PodcastEstimate | null => { + if (!raw || typeof raw !== "object") return null; + const numeric = ["ttsCost", "avatarCost", "videoCost", "researchCost", "total"] as const; + if (numeric.some((key) => typeof raw[key] !== "number" || Number.isNaN(raw[key]))) { + return null; + } + const isCustomVoice = Boolean( + voiceId && + ![ + "Wise_Woman", + "Friendly_Person", + "Inspirational_girl", + "Deep_Voice_Man", + "Calm_Woman", + "Casual_Guy", + "Lively_Girl", + "Patient_Man", + "Young_Knight", + "Determined_Man", + "Lovely_Girl", + "Decent_Boy", + "Imposing_Manner", + "Elegant_Man", + "Abbess", + "Sweet_Girl_2", + "Exuberant_Girl", + ].includes(voiceId) + ); return { - ttsCost: +ttsCost.toFixed(2), - avatarCost: +avatarCost.toFixed(2), - videoCost: +videoCost.toFixed(2), - researchCost, - total, - voiceName, + ttsCost: raw.ttsCost, + avatarCost: raw.avatarCost, + videoCost: raw.videoCost, + researchCost: raw.researchCost, + total: raw.total, + voiceName: isCustomVoice ? "My Voice Clone" : (!voiceId ? "Wise Woman" : voiceId.replace(/_/g, " ")), isCustomVoice, }; }; @@ -173,12 +175,14 @@ const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => { type ExaResearchResult = { sources: ExaSource[]; search_queries?: string[]; - cost_est?: { +cost_est?: { total?: number; breakdown?: { phase: "Analyze" | "Gather" | "Write" | "Produce"; cost: number }[]; currency?: "USD"; last_updated?: string; }; + cost?: { total?: number }; + estimate?: PodcastEstimate | null; search_type?: string; provider?: string; content?: string; @@ -302,15 +306,7 @@ export const podcastApi = { // so users can manually choose which queries to run const projectId = createId("podcast"); - const estimate = estimateCosts({ - minutes: payload.duration, - scenes: Math.ceil((payload.duration * 60) / (payload.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target)), - chars: Math.max(1000, payload.duration * 900), - quality: payload.knobs.bitrate || "standard", - avatars: payload.speakers, - queryCount: queries.length || 3, - voiceId: payload.knobs.voice_id, - }); + const estimate = toPodcastEstimate(analysisResp.data?.estimate, payload.knobs.voice_id); return { projectId, @@ -337,7 +333,7 @@ export const podcastApi = { bible?: any; analysis?: PodcastAnalysis | null; onProgress?: (message: string) => void; - }): Promise<{ research: Research; raw: any }> { + }): Promise<{ research: Research; raw: any; estimate?: PodcastEstimate | null }> { const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean); if (!keywords.length) { throw new Error("At least one query must be approved for research."); @@ -384,7 +380,11 @@ export const podcastApi = { params.onProgress("Deep research completed with Exa."); } const mapped = mapExaResearchResponse(exaResult); - return { research: mapped, raw: exaResult }; + return { + research: mapped, + raw: exaResult, + estimate: toPodcastEstimate(exaResult.estimate, params.analysis?.suggestedKnobs?.voice_id), + }; }, async generateScript(params: {