From f2103101776b08a483b0188d7e49c88d1ef2f635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=8A?= Date: Sun, 19 Apr 2026 16:28:39 +0530 Subject: [PATCH] Use backend-provided podcast estimates and remove UI heuristics --- backend/api/podcast/handlers/analysis.py | 86 ++++++++++++++++++- backend/api/podcast/models.py | 1 + .../components/PodcastMaker/CreateModal.tsx | 22 +---- .../PodcastMaker/CreateStep/TopicUrlInput.tsx | 18 +++- frontend/src/components/PodcastMaker/types.ts | 5 +- frontend/src/services/podcastApi.ts | 61 ++++--------- 6 files changed, 122 insertions(+), 71 deletions(-) diff --git a/backend/api/podcast/handlers/analysis.py b/backend/api/podcast/handlers/analysis.py index c5d389ca..4cbf2ed6 100644 --- a/backend/api/podcast/handlers/analysis.py +++ b/backend/api/podcast/handlers/analysis.py @@ -17,6 +17,8 @@ from api.story_writer.utils.auth import require_authenticated_user from services.llm_providers.main_text_generation import llm_text_gen from services.llm_providers.main_image_generation import generate_image from services.podcast_bible_service import PodcastBibleService +from services.subscription import PricingService +from models.subscription_models import APIProvider from utils.asset_tracker import save_asset_to_library from loguru import logger import os @@ -33,6 +35,82 @@ def _is_podcast_only_mode() -> bool: """Check if podcast-only demo mode is enabled.""" return os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() == "podcast" + +def _estimate_tokens(text: str) -> int: + if not text: + return 0 + return max(1, len(text) // 4) + + +def _build_analysis_estimate( + db: Session, + idea: str, + duration: int, + speakers: int, + has_avatar: bool, +) -> Dict[str, Any]: + """ + Build a user-facing estimate from pricing catalog and phase-level assumptions. + """ + # Defaults if catalog lookup fails + gemini_in_token = 0.00000015 + gemini_out_token = 0.0000006 + exa_per_request = 0.005 + image_per_request = 0.01 + video_per_request = 0.01 + audio_per_request = 0.005 + + try: + pricing_service = PricingService(db) + gemini_pricing = pricing_service.get_pricing_for_provider_model(APIProvider.GEMINI, "gemini-2.5-flash") or {} + gemini_in_token = float(gemini_pricing.get("cost_per_input_token") or gemini_in_token) + gemini_out_token = float(gemini_pricing.get("cost_per_output_token") or gemini_out_token) + exa_pricing = pricing_service.get_pricing_for_provider_model(APIProvider.EXA, "exa-search") or {} + exa_per_request = float(exa_pricing.get("cost_per_request") or exa_per_request) + img_pricing = pricing_service.get_pricing_for_provider_model(APIProvider.STABILITY, "stable-image-ultra") or {} + image_per_request = float(img_pricing.get("cost_per_request") or image_per_request) + video_pricing = pricing_service.get_pricing_for_provider_model(APIProvider.VIDEO, "minimax-video-01") or {} + video_per_request = float(video_pricing.get("cost_per_request") or video_per_request) + audio_pricing = pricing_service.get_pricing_for_provider_model(APIProvider.AUDIO, "gemini-2.5-flash-preview-tts") or {} + audio_per_request = float(audio_pricing.get("cost_per_request") or audio_per_request) + except Exception as exc: + logger.warning(f"[Podcast Analyze] Pricing catalog lookup failed, using defaults: {exc}") + + # Phase assumptions + query_count = 5 + analyze_in = _estimate_tokens(idea) + 240 + analyze_out = 750 + analyze_cost = (analyze_in * gemini_in_token) + (analyze_out * gemini_out_token) + + gather_cost = query_count * exa_per_request + + script_chars = max(1000, duration * 900) + write_in = _estimate_tokens(idea) + _estimate_tokens(str(script_chars)) + 320 + write_out = max(900, int(duration * 220)) + write_cost = (write_in * gemini_in_token) + (write_out * gemini_out_token) + + tts_cost = max(1, speakers) * audio_per_request + avatar_cost = 0.0 if has_avatar else image_per_request + video_cost = max(1, duration) * video_per_request + produce_cost = tts_cost + avatar_cost + video_cost + + breakdown = [ + {"phase": "Analyze", "cost": round(analyze_cost, 6)}, + {"phase": "Gather", "cost": round(gather_cost, 6)}, + {"phase": "Write", "cost": round(write_cost, 6)}, + {"phase": "Produce", "cost": round(produce_cost, 6)}, + ] + total = round(sum(item["cost"] for item in breakdown), 6) + return { + "ttsCost": round(tts_cost, 6), + "avatarCost": round(avatar_cost, 6), + "videoCost": round(video_cost, 6), + "researchCost": round(gather_cost, 6), + "total": total, + "breakdown": breakdown, + "currency": "USD", + } + router = APIRouter() @@ -388,6 +466,13 @@ Requirements: bible=bible_obj.model_dump() if bible_obj else None, avatar_url=final_avatar_url, avatar_prompt=final_avatar_prompt, + estimate=_build_analysis_estimate( + db=db, + idea=request.idea, + duration=request.duration, + speakers=request.speakers, + has_avatar=bool(final_avatar_url), + ), ) @@ -492,4 +577,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/models.py b/backend/api/podcast/models.py index 8f9723ca..33e99942 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): 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..7e41624d 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,7 +115,7 @@ export const TopicUrlInput: React.FC = ({ - {estimatedCost && ( + {estimatedCost ? ( @@ -171,6 +171,20 @@ export const TopicUrlInput: React.FC = ({ }} /> + ) : ( + } + label="Est. Unavailable" + size="small" + sx={{ + background: "rgba(148, 163, 184, 0.12)", + color: "#64748b", + fontWeight: 600, + border: "1px solid rgba(148, 163, 184, 0.2)", + fontSize: "0.75rem", + height: 26, + }} + /> )} ; + currency?: "USD"; + lastUpdated?: string; voiceName?: string; isCustomVoice?: boolean; }; @@ -196,7 +199,7 @@ export type CreateProjectPayload = { export type CreateProjectResult = { projectId: string; analysis: PodcastAnalysis; - estimate: PodcastEstimate; + estimate: PodcastEstimate | null; queries: Query[]; bible?: PodcastBible; avatar_url?: string | null; diff --git a/frontend/src/services/podcastApi.ts b/frontend/src/services/podcastApi.ts index 8ba34689..ea409e67 100644 --- a/frontend/src/services/podcastApi.ts +++ b/frontend/src/services/podcastApi.ts @@ -59,43 +59,6 @@ const deriveSegments = (option?: OptionLike): string[] => { 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, " ")); - return { - ttsCost: +ttsCost.toFixed(2), - avatarCost: +avatarCost.toFixed(2), - videoCost: +videoCost.toFixed(2), - researchCost, - total, - voiceName, - isCustomVoice, - }; -}; - const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string): Query[] => { const baseIdea = seed || "AI marketing for small businesses"; const personaKeywords = persona?.suggested_keywords?.filter(Boolean) || []; @@ -302,15 +265,21 @@ 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 estimateData = analysisResp.data?.estimate; + const estimate: PodcastEstimate | null = estimateData + ? { + ttsCost: Number(estimateData.ttsCost ?? 0), + avatarCost: Number(estimateData.avatarCost ?? 0), + videoCost: Number(estimateData.videoCost ?? 0), + researchCost: Number(estimateData.researchCost ?? 0), + total: Number(estimateData.total ?? 0), + breakdown: Array.isArray(estimateData.breakdown) ? estimateData.breakdown : [], + currency: estimateData.currency || "USD", + lastUpdated: estimateData.last_updated || estimateData.lastUpdated, + voiceName: estimateData.voiceName, + isCustomVoice: estimateData.isCustomVoice, + } + : null; return { projectId,