Move podcast cost estimates to backend pricing catalog
This commit is contained in:
133
backend/api/podcast/cost_estimator.py
Normal file
133
backend/api/podcast/cost_estimator.py
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ from ..models import (
|
|||||||
PodcastEnhanceIdeaRequest,
|
PodcastEnhanceIdeaRequest,
|
||||||
PodcastEnhanceIdeaResponse
|
PodcastEnhanceIdeaResponse
|
||||||
)
|
)
|
||||||
|
from ..cost_estimator import estimate_podcast_cost
|
||||||
|
|
||||||
# Check if running in podcast-only demo mode
|
# Check if running in podcast-only demo mode
|
||||||
def _is_podcast_only_mode() -> bool:
|
def _is_podcast_only_mode() -> bool:
|
||||||
@@ -372,6 +373,13 @@ Requirements:
|
|||||||
listener_cta = data.get("listener_cta") or ""
|
listener_cta = data.get("listener_cta") or ""
|
||||||
research_queries = data.get("research_queries") or []
|
research_queries = data.get("research_queries") or []
|
||||||
exa_suggested_config = data.get("exa_suggested_config") or None
|
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(
|
return PodcastAnalyzeResponse(
|
||||||
audience=audience,
|
audience=audience,
|
||||||
@@ -388,6 +396,7 @@ Requirements:
|
|||||||
bible=bible_obj.model_dump() if bible_obj else None,
|
bible=bible_obj.model_dump() if bible_obj else None,
|
||||||
avatar_url=final_avatar_url,
|
avatar_url=final_avatar_url,
|
||||||
avatar_prompt=final_avatar_prompt,
|
avatar_prompt=final_avatar_prompt,
|
||||||
|
estimate=estimate,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -492,4 +501,3 @@ Requirements:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"[Regenerate Queries] Failed for user {user_id}: {exc}")
|
logger.error(f"[Regenerate Queries] Failed for user {user_id}: {exc}")
|
||||||
raise HTTPException(status_code=500, detail=f"Regenerate queries failed: {exc}")
|
raise HTTPException(status_code=500, detail=f"Regenerate queries failed: {exc}")
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ from typing import Dict, Any, List
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from middleware.auth_middleware import get_current_user
|
from middleware.auth_middleware import get_current_user
|
||||||
from api.story_writer.utils.auth import require_authenticated_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.blog_writer.research.exa_provider import ExaResearchProvider
|
||||||
from services.llm_providers.main_text_generation import llm_text_gen
|
from services.llm_providers.main_text_generation import llm_text_gen
|
||||||
from services.podcast_bible_service import PodcastBibleService
|
from services.podcast_bible_service import PodcastBibleService
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from ..cost_estimator import estimate_podcast_cost
|
||||||
from ..models import (
|
from ..models import (
|
||||||
PodcastExaResearchRequest,
|
PodcastExaResearchRequest,
|
||||||
PodcastExaResearchResponse,
|
PodcastExaResearchResponse,
|
||||||
@@ -32,6 +35,7 @@ router = APIRouter()
|
|||||||
async def podcast_research_exa(
|
async def podcast_research_exa(
|
||||||
request: PodcastExaResearchRequest,
|
request: PodcastExaResearchRequest,
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
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.
|
Run podcast research via Exa and then use LLM to extract deep insights.
|
||||||
@@ -297,6 +301,20 @@ QUALITY STANDARDS:
|
|||||||
"credibility_score": src.get("credibility_score"),
|
"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(
|
return PodcastExaResearchResponse(
|
||||||
sources=sources_payload,
|
sources=sources_payload,
|
||||||
search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries,
|
search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries,
|
||||||
@@ -306,5 +324,5 @@ QUALITY STANDARDS:
|
|||||||
search_type=result.get("search_type") if isinstance(result, dict) else None,
|
search_type=result.get("search_type") if isinstance(result, dict) else None,
|
||||||
provider=result.get("provider", "exa") if isinstance(result, dict) else "exa",
|
provider=result.get("provider", "exa") if isinstance(result, dict) else "exa",
|
||||||
content=raw_content,
|
content=raw_content,
|
||||||
|
estimate=estimate,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class PodcastAnalyzeResponse(BaseModel):
|
|||||||
bible: Optional[Dict[str, Any]] = None
|
bible: Optional[Dict[str, Any]] = None
|
||||||
avatar_url: Optional[str] = None
|
avatar_url: Optional[str] = None
|
||||||
avatar_prompt: Optional[str] = None
|
avatar_prompt: Optional[str] = None
|
||||||
|
estimate: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
class PodcastEnhanceIdeaRequest(BaseModel):
|
class PodcastEnhanceIdeaRequest(BaseModel):
|
||||||
@@ -193,6 +194,7 @@ class PodcastExaResearchResponse(BaseModel):
|
|||||||
mapped_angles: List[Dict[str, Any]] = [] # Content angles for the episode
|
mapped_angles: List[Dict[str, Any]] = [] # Content angles for the episode
|
||||||
expert_quotes: List[Dict[str, Any]] = [] # Expert quotes from research
|
expert_quotes: List[Dict[str, Any]] = [] # Expert quotes from research
|
||||||
listener_cta_suggestions: List[str] = [] # CTA suggestions
|
listener_cta_suggestions: List[str] = [] # CTA suggestions
|
||||||
|
estimate: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
class PodcastScriptResponse(BaseModel):
|
class PodcastScriptResponse(BaseModel):
|
||||||
@@ -450,4 +452,3 @@ class VoiceCloneResult(BaseModel):
|
|||||||
file_size: int
|
file_size: int
|
||||||
task_id: str
|
task_id: str
|
||||||
status: str = "completed"
|
status: str = "completed"
|
||||||
|
|
||||||
|
|||||||
@@ -253,26 +253,6 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
|||||||
setShowAIDetailsButton(topicInput.trim().length > 0 && !isUrl);
|
setShowAIDetailsButton(topicInput.trim().length > 0 && !isUrl);
|
||||||
}, [topicInput, 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)
|
// Check if avatar is present (from any source: upload, brand avatar, or generated)
|
||||||
const hasAvatar = Boolean(
|
const hasAvatar = Boolean(
|
||||||
avatarFile || // User uploaded an image
|
avatarFile || // User uploaded an image
|
||||||
@@ -560,7 +540,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
|||||||
placeholderIndex={placeholderIndex}
|
placeholderIndex={placeholderIndex}
|
||||||
loading={enhancingTopic}
|
loading={enhancingTopic}
|
||||||
loadingMessage={enhanceTopicMessage}
|
loadingMessage={enhanceTopicMessage}
|
||||||
estimatedCost={estimatedCost}
|
estimatedCost={null}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
speakers={speakers}
|
speakers={speakers}
|
||||||
knobs={knobs}
|
knobs={knobs}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ interface TopicUrlInputProps {
|
|||||||
videoCost: number;
|
videoCost: number;
|
||||||
researchCost: number;
|
researchCost: number;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
} | null;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
speakers?: number;
|
speakers?: number;
|
||||||
knobs?: Knobs;
|
knobs?: Knobs;
|
||||||
@@ -115,9 +115,9 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{estimatedCost && (
|
<Tooltip
|
||||||
<Tooltip
|
title={
|
||||||
title={
|
estimatedCost ? (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||||
Estimated Cost Breakdown:
|
Estimated Cost Breakdown:
|
||||||
@@ -135,43 +135,49 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
) : (
|
||||||
arrow
|
"Estimate unavailable until returned by the server."
|
||||||
placement="top"
|
)
|
||||||
componentsProps={{
|
}
|
||||||
tooltip: {
|
arrow
|
||||||
sx: {
|
placement="top"
|
||||||
bgcolor: "#0f172a",
|
componentsProps={{
|
||||||
color: "#ffffff",
|
tooltip: {
|
||||||
maxWidth: 280,
|
sx: {
|
||||||
fontSize: "0.875rem",
|
bgcolor: "#0f172a",
|
||||||
p: 1.5,
|
color: "#ffffff",
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
maxWidth: 280,
|
||||||
},
|
fontSize: "0.875rem",
|
||||||
|
p: 1.5,
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||||
},
|
},
|
||||||
arrow: {
|
},
|
||||||
sx: {
|
arrow: {
|
||||||
color: "#0f172a",
|
sx: {
|
||||||
},
|
color: "#0f172a",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||||
|
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",
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Chip
|
</Tooltip>
|
||||||
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
|
|
||||||
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",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
try {
|
try {
|
||||||
console.log('[Research] Starting research with:', { topic: project.idea, approvedQueries, provider: researchProvider });
|
console.log('[Research] Starting research with:', { topic: project.idea, approvedQueries, provider: researchProvider });
|
||||||
console.log('[Research] Calling podcastApi.runResearch...');
|
console.log('[Research] Calling podcastApi.runResearch...');
|
||||||
const { research: mapped, raw } = await podcastApi.runResearch({
|
const { research: mapped, raw, estimate } = await podcastApi.runResearch({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
topic: project.idea,
|
topic: project.idea,
|
||||||
approvedQueries,
|
approvedQueries,
|
||||||
@@ -369,6 +369,9 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
console.log('[Research] Response received:', { mapped, raw });
|
console.log('[Research] Response received:', { mapped, raw });
|
||||||
setResearch(mapped);
|
setResearch(mapped);
|
||||||
setRawResearch(raw);
|
setRawResearch(raw);
|
||||||
|
if (estimate) {
|
||||||
|
setEstimate(estimate);
|
||||||
|
}
|
||||||
setAnnouncement("Research complete — review fact cards below");
|
setAnnouncement("Research complete — review fact cards below");
|
||||||
} catch (researchError) {
|
} catch (researchError) {
|
||||||
const errorMessage = researchError instanceof Error
|
const errorMessage = researchError instanceof Error
|
||||||
@@ -392,7 +395,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
} finally {
|
} finally {
|
||||||
setIsResearching(false);
|
setIsResearching(false);
|
||||||
}
|
}
|
||||||
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]);
|
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setEstimate, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]);
|
||||||
|
|
||||||
// Add a ref to track if we're currently generating to prevent double calls
|
// Add a ref to track if we're currently generating to prevent double calls
|
||||||
const isGeneratingRef = useRef(false);
|
const isGeneratingRef = useRef(false);
|
||||||
@@ -625,4 +628,3 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
handleDeleteQuery,
|
handleDeleteQuery,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export type CreateProjectPayload = {
|
|||||||
export type CreateProjectResult = {
|
export type CreateProjectResult = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
analysis: PodcastAnalysis;
|
analysis: PodcastAnalysis;
|
||||||
estimate: PodcastEstimate;
|
estimate: PodcastEstimate | null;
|
||||||
queries: Query[];
|
queries: Query[];
|
||||||
bible?: PodcastBible;
|
bible?: PodcastBible;
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
@@ -222,4 +222,3 @@ export type TaskStatus = {
|
|||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -59,39 +59,41 @@ const deriveSegments = (option?: OptionLike): string[] => {
|
|||||||
return segments.slice(0, 5);
|
return segments.slice(0, 5);
|
||||||
};
|
};
|
||||||
|
|
||||||
const estimateCosts = ({
|
const toPodcastEstimate = (raw: any, voiceId?: string): PodcastEstimate | null => {
|
||||||
minutes,
|
if (!raw || typeof raw !== "object") return null;
|
||||||
scenes,
|
const numeric = ["ttsCost", "avatarCost", "videoCost", "researchCost", "total"] as const;
|
||||||
chars,
|
if (numeric.some((key) => typeof raw[key] !== "number" || Number.isNaN(raw[key]))) {
|
||||||
quality,
|
return null;
|
||||||
avatars,
|
}
|
||||||
queryCount = 3,
|
const isCustomVoice = Boolean(
|
||||||
voiceId,
|
voiceId &&
|
||||||
}: {
|
![
|
||||||
minutes: number;
|
"Wise_Woman",
|
||||||
scenes: number;
|
"Friendly_Person",
|
||||||
chars: number;
|
"Inspirational_girl",
|
||||||
quality: string;
|
"Deep_Voice_Man",
|
||||||
avatars: number;
|
"Calm_Woman",
|
||||||
queryCount?: number;
|
"Casual_Guy",
|
||||||
voiceId?: string;
|
"Lively_Girl",
|
||||||
}): PodcastEstimate => {
|
"Patient_Man",
|
||||||
const secs = Math.max(60, minutes * 60);
|
"Young_Knight",
|
||||||
const ttsCost = (chars / 1000) * 0.05;
|
"Determined_Man",
|
||||||
const avatarCost = avatars * 0.15;
|
"Lovely_Girl",
|
||||||
const videoRate = quality === "hd" ? 0.06 : 0.03;
|
"Decent_Boy",
|
||||||
const videoCost = secs * videoRate;
|
"Imposing_Manner",
|
||||||
const researchCost = +(Math.max(1, queryCount) * 0.1).toFixed(2);
|
"Elegant_Man",
|
||||||
const total = +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2);
|
"Abbess",
|
||||||
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));
|
"Sweet_Girl_2",
|
||||||
const voiceName = isCustomVoice ? "My Voice Clone" : (!voiceId ? "Wise Woman" : voiceId.replace(/_/g, " "));
|
"Exuberant_Girl",
|
||||||
|
].includes(voiceId)
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
ttsCost: +ttsCost.toFixed(2),
|
ttsCost: raw.ttsCost,
|
||||||
avatarCost: +avatarCost.toFixed(2),
|
avatarCost: raw.avatarCost,
|
||||||
videoCost: +videoCost.toFixed(2),
|
videoCost: raw.videoCost,
|
||||||
researchCost,
|
researchCost: raw.researchCost,
|
||||||
total,
|
total: raw.total,
|
||||||
voiceName,
|
voiceName: isCustomVoice ? "My Voice Clone" : (!voiceId ? "Wise Woman" : voiceId.replace(/_/g, " ")),
|
||||||
isCustomVoice,
|
isCustomVoice,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -174,6 +176,7 @@ type ExaResearchResult = {
|
|||||||
sources: ExaSource[];
|
sources: ExaSource[];
|
||||||
search_queries?: string[];
|
search_queries?: string[];
|
||||||
cost?: { total?: number };
|
cost?: { total?: number };
|
||||||
|
estimate?: PodcastEstimate | null;
|
||||||
search_type?: string;
|
search_type?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -290,15 +293,7 @@ export const podcastApi = {
|
|||||||
// so users can manually choose which queries to run
|
// so users can manually choose which queries to run
|
||||||
|
|
||||||
const projectId = createId("podcast");
|
const projectId = createId("podcast");
|
||||||
const estimate = estimateCosts({
|
const estimate = toPodcastEstimate(analysisResp.data?.estimate, payload.knobs.voice_id);
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -325,7 +320,7 @@ export const podcastApi = {
|
|||||||
bible?: any;
|
bible?: any;
|
||||||
analysis?: PodcastAnalysis | null;
|
analysis?: PodcastAnalysis | null;
|
||||||
onProgress?: (message: string) => void;
|
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);
|
const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean);
|
||||||
if (!keywords.length) {
|
if (!keywords.length) {
|
||||||
throw new Error("At least one query must be approved for research.");
|
throw new Error("At least one query must be approved for research.");
|
||||||
@@ -372,7 +367,11 @@ export const podcastApi = {
|
|||||||
params.onProgress("Deep research completed with Exa.");
|
params.onProgress("Deep research completed with Exa.");
|
||||||
}
|
}
|
||||||
const mapped = mapExaResearchResponse(exaResult);
|
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: {
|
async generateScript(params: {
|
||||||
@@ -953,4 +952,3 @@ export const podcastApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PodcastApi = typeof podcastApi;
|
export type PodcastApi = typeof podcastApi;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user