AI podcast maker performance optimizations

This commit is contained in:
ajaysi
2025-12-12 21:43:09 +05:30
parent 81590cf4db
commit eba5210577
46 changed files with 6176 additions and 1648 deletions

View File

@@ -0,0 +1,56 @@
import React from "react";
import { Stack, Typography, Chip, Divider } from "@mui/material";
import { Insights as InsightsIcon } from "@mui/icons-material";
import { PodcastEstimate } from "../types";
import { GlassyCard, glassyCardSx } from "../ui";
interface EstimateCardProps {
estimate: PodcastEstimate;
}
export const EstimateCard: React.FC<EstimateCardProps> = ({ estimate }) => {
return (
<GlassyCard
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a",
}}
aria-label="estimate"
>
<Stack spacing={2}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<InsightsIcon />
Estimated Cost
</Typography>
<Typography variant="h4" sx={{ color: "#4f46e5", fontWeight: 800 }}>
${estimate.total.toFixed(2)}
</Typography>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<Chip
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
size="small"
title="Voice narration cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
<Chip
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
size="small"
title="Avatar/video cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
<Chip
label={`Research: $${estimate.researchCost.toFixed(2)}`}
size="small"
title="Research and fact-checking cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
</Stack>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,68 @@
import React from "react";
import { Box, Stack, Typography } from "@mui/material";
import {
Mic as MicIcon,
Info as InfoIcon,
AutoAwesome as AutoAwesomeIcon,
LibraryMusic as LibraryMusicIcon,
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import { PrimaryButton, SecondaryButton } from "../ui";
import HeaderControls from "../../shared/HeaderControls";
interface HeaderProps {
onShowProjects: () => void;
onNewEpisode: () => void;
}
export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode }) => {
const navigate = useNavigate();
return (
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box>
<Typography
variant="h3"
sx={{
color: "#1e293b",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1.5,
}}
>
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
AI Podcast Maker
</Typography>
<Typography variant="body2" color="text.secondary">
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
<SecondaryButton onClick={() => window.open("/docs", "_blank")} startIcon={<InfoIcon />}>
Help
</SecondaryButton>
<SecondaryButton
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
startIcon={<LibraryMusicIcon />}
tooltip="View all podcast episodes in Asset Library"
>
My Episodes
</SecondaryButton>
<SecondaryButton
onClick={onShowProjects}
startIcon={<MicIcon />}
tooltip="View and resume saved projects"
>
My Projects
</SecondaryButton>
<PrimaryButton onClick={onNewEpisode} startIcon={<AutoAwesomeIcon />}>
New Episode
</PrimaryButton>
</Stack>
</Stack>
);
};

View File

@@ -0,0 +1,105 @@
import React from "react";
import { Box, Paper, Stepper, Step, StepLabel, Typography, alpha } from "@mui/material";
import {
Psychology as PsychologyIcon,
Search as SearchIcon,
EditNote as EditNoteIcon,
PlayArrow as PlayArrowIcon,
CheckCircle as CheckCircleIcon,
} from "@mui/icons-material";
interface ProgressStepperProps {
activeStep: number;
completedSteps?: number[]; // Steps that have been completed (have data)
onStepClick?: (stepIndex: number) => void;
}
const steps = [
{ label: "Analysis", icon: <PsychologyIcon />, description: "AI analyzes your idea" },
{ label: "Research", icon: <SearchIcon />, description: "Gather facts and citations" },
{ label: "Script", icon: <EditNoteIcon />, description: "Edit and approve scenes" },
{ label: "Render", icon: <PlayArrowIcon />, description: "Generate audio files" },
];
export const ProgressStepper: React.FC<ProgressStepperProps> = ({ activeStep, completedSteps = [], onStepClick }) => {
if (activeStep < 0) return null;
const handleStepClick = (stepIndex: number) => {
// Allow navigation to any completed step (has data), not just steps before active step
const isCompleted = completedSteps.includes(stepIndex);
if (isCompleted && onStepClick) {
onStepClick(stepIndex);
}
};
return (
<Paper
sx={{
p: 2.5,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 2,
}}
>
<Stepper activeStep={activeStep} orientation="horizontal">
{steps.map((step, index) => {
const isCompleted = completedSteps.includes(index);
const isClickable = isCompleted && onStepClick !== undefined;
return (
<Step key={step.label} completed={isCompleted}>
<StepLabel
onClick={() => handleStepClick(index)}
sx={{
cursor: isClickable ? "pointer" : "default",
"&:hover": isClickable
? {
"& .MuiStepLabel-label": {
color: "#667eea",
},
}
: {},
}}
StepIconComponent={({ active, completed }) => (
<Box
sx={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: completed
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: active
? alpha("#667eea", 0.15)
: "#e2e8f0",
border: active ? "2px solid #667eea" : "1px solid rgba(0,0,0,0.1)",
color: completed || active ? "#fff" : "#64748b",
transition: "all 0.2s ease",
...(isClickable && {
cursor: "pointer",
"&:hover": {
transform: "scale(1.05)",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
},
}),
}}
>
{completed ? <CheckCircleIcon /> : step.icon}
</Box>
)}
>
<Typography variant="subtitle2">{step.label}</Typography>
<Typography variant="caption" color="text.secondary">
{step.description}
</Typography>
</StepLabel>
</Step>
);
})}
</Stepper>
</Paper>
);
};

View File

@@ -0,0 +1,169 @@
import React from "react";
import {
Stack,
Typography,
Chip,
Tooltip,
Alert,
List,
ListItem,
ListItemButton,
ListItemText,
Checkbox,
FormControl,
InputLabel,
Select,
MenuItem,
Box,
alpha,
} from "@mui/material";
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
import { ResearchProvider } from "../../../services/blogWriterApi";
import { Query } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
interface QuerySelectionProps {
queries: Query[];
selectedQueries: Set<string>;
researchProvider: ResearchProvider;
isResearching: boolean;
onToggleQuery: (id: string) => void;
onProviderChange: (provider: ResearchProvider) => void;
onRunResearch: () => void;
}
export const QuerySelection: React.FC<QuerySelectionProps> = ({
queries,
selectedQueries,
researchProvider,
isResearching,
onToggleQuery,
onProviderChange,
onRunResearch,
}) => {
const selectedCount = selectedQueries.size;
return (
<GlassyCard
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a",
}}
>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<SearchIcon />
Research Queries
</Typography>
<Stack direction="row" spacing={2} alignItems="center">
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Provider</InputLabel>
<Select
value={researchProvider}
onChange={(e) => onProviderChange(e.target.value as ResearchProvider)}
label="Provider"
disabled={isResearching}
size="small"
sx={{
backgroundColor: "#f8fafc",
"&:hover": {
backgroundColor: "#f1f5f9",
},
}}
>
<MenuItem value="google">
<Stack direction="row" spacing={1} alignItems="center">
<SearchIcon fontSize="small" />
<span>Standard Research</span>
</Stack>
</MenuItem>
<MenuItem value="exa">
<Stack direction="row" spacing={1} alignItems="center">
<AutoAwesomeIcon fontSize="small" />
<span>Deep Research</span>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Chip
label={`${selectedCount} / ${queries.length} selected`}
size="small"
color={selectedCount > 0 ? "primary" : "default"}
/>
</Stack>
</Stack>
<Tooltip
title={
researchProvider === "google"
? "Standard Research: Fast, fact-checked results with source citations"
: "Deep Research: Comprehensive analysis with competitor insights and trending topics"
}
arrow
>
<Alert
severity="info"
sx={{
background: "#e0f2fe",
border: "1px solid #bae6fd",
color: "#0f172a",
}}
>
<Typography variant="caption" sx={{ color: "#0f172a" }}>
{researchProvider === "google"
? "Select at least one query (recommended: 3+ for balanced coverage). Standard research provides fact-checked results with source citations."
: "Select queries for deep research. This mode provides comprehensive analysis with competitor insights and trending topics."}
</Typography>
</Alert>
</Tooltip>
<List>
{queries.map((q) => (
<ListItem key={q.id} disablePadding>
<ListItemButton
onClick={() => onToggleQuery(q.id)}
disabled={isResearching}
sx={{
borderRadius: 2,
mb: 1,
border: "1px solid rgba(0,0,0,0.08)",
background: "#f8fafc",
"&:hover": { background: alpha("#667eea", 0.08) },
}}
>
<Checkbox checked={selectedQueries.has(q.id)} edge="start" />
<ListItemText
primary={q.query}
secondary={q.rationale}
primaryTypographyProps={{ variant: "body2", fontWeight: 600, color: "#0f172a" }}
secondaryTypographyProps={{ variant: "caption", sx: { color: "#475569" } }}
/>
</ListItemButton>
</ListItem>
))}
</List>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<PrimaryButton
onClick={onRunResearch}
disabled={selectedCount === 0 || isResearching}
loading={isResearching}
startIcon={<SearchIcon />}
tooltip={
selectedCount === 0
? "Select at least one query to run research"
: `Run research with ${selectedCount} selected ${selectedCount === 1 ? "query" : "queries"}`
}
>
{isResearching ? "Running Research..." : "Run Research"}
</PrimaryButton>
</Box>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,148 @@
import React from "react";
import { Stack, Typography, Chip, Divider, Box, alpha } from "@mui/material";
import {
Insights as InsightsIcon,
Search as SearchIcon,
AttachMoney as AttachMoneyIcon,
EditNote as EditNoteIcon,
} from "@mui/icons-material";
import { Research } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { FactCard } from "../FactCard";
interface ResearchSummaryProps {
research: Research;
canGenerateScript: boolean;
onGenerateScript: () => void;
}
export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
research,
canGenerateScript,
onGenerateScript,
}) => {
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
<InsightsIcon />
Research Summary
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, lineHeight: 1.7 }}>
{research.summary}
</Typography>
{/* Research Metadata */}
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap sx={{ mb: 2 }}>
{research.searchQueries && research.searchQueries.length > 0 && (
<Chip
icon={<SearchIcon />}
label={`${research.searchQueries.length} search${research.searchQueries.length > 1 ? "es" : ""}`}
size="small"
sx={{
background: alpha("#667eea", 0.1),
color: "#667eea",
fontWeight: 600,
border: "1px solid rgba(102, 126, 234, 0.2)",
}}
/>
)}
{research.searchType && (
<Chip
label={`${research.searchType.charAt(0).toUpperCase() + research.searchType.slice(1)} search`}
size="small"
sx={{
background: alpha("#10b981", 0.1),
color: "#059669",
fontWeight: 600,
border: "1px solid rgba(16, 185, 129, 0.2)",
}}
/>
)}
{research.sourceCount !== undefined && (
<Chip
label={`${research.sourceCount} source${research.sourceCount !== 1 ? "s" : ""}`}
size="small"
sx={{
background: alpha("#6366f1", 0.1),
color: "#4f46e5",
fontWeight: 600,
border: "1px solid rgba(99, 102, 241, 0.2)",
}}
/>
)}
{research.cost !== undefined && (
<Chip
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
label={`$${research.cost.toFixed(3)}`}
size="small"
sx={{
background: alpha("#f59e0b", 0.1),
color: "#d97706",
fontWeight: 600,
border: "1px solid rgba(245, 158, 11, 0.2)",
}}
/>
)}
</Stack>
{/* Search Queries Used */}
{research.searchQueries && research.searchQueries.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 600 }}>
Search Queries Used
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{research.searchQueries.map((query, idx) => (
<Chip
key={idx}
label={query}
size="small"
variant="outlined"
sx={{
borderColor: "rgba(102, 126, 234, 0.3)",
color: "#475569",
background: alpha("#f8fafc", 0.8),
fontSize: "0.8125rem",
}}
/>
))}
</Stack>
</Box>
)}
</Box>
<PrimaryButton
onClick={onGenerateScript}
disabled={!canGenerateScript}
startIcon={<EditNoteIcon />}
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
>
Generate Script
</PrimaryButton>
</Stack>
{research.factCards.length > 0 && (
<>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600 }}>
Research Sources & Facts ({research.factCards.length})
</Typography>
<Typography variant="caption" sx={{ color: "#64748b" }}>
Click any card to view source details
</Typography>
</Stack>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", lg: "1fr 1fr 1fr" }, gap: 2 }}>
{research.factCards.map((fact) => (
<FactCard key={fact.id} fact={fact} />
))}
</Box>
</>
)}
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,8 @@
export { Header } from "./Header";
export { ProgressStepper } from "./ProgressStepper";
export { EstimateCard } from "./EstimateCard";
export { QuerySelection } from "./QuerySelection";
export { ResearchSummary } from "./ResearchSummary";
export { usePodcastWorkflow } from "./usePodcastWorkflow";
export * from "./utils";

View File

@@ -0,0 +1,302 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { ResearchProvider, ResearchConfig } from "../../../services/blogWriterApi";
import { podcastApi } from "../../../services/podcastApi";
import { usePreflightCheck } from "../../../hooks/usePreflightCheck";
import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
import { CreateProjectPayload, Query, Research, Script, Job } from "../types";
import { usePodcastProjectState } from "../../../hooks/usePodcastProjectState";
import { sanitizeExaConfig, announceError, getStepLabel } from "./utils";
type PodcastProjectStateReturn = ReturnType<typeof usePodcastProjectState>;
interface UsePodcastWorkflowProps {
projectState: PodcastProjectStateReturn;
onError: (message: string) => void;
}
export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflowProps) => {
const {
project,
analysis,
queries,
selectedQueries,
research,
rawResearch,
researchProvider,
showScriptEditor,
showRenderQueue,
currentStep,
renderJobs,
budgetCap,
setProject,
setAnalysis,
setQueries,
setSelectedQueries,
setResearch,
setRawResearch,
setEstimate,
setScriptData,
setShowScriptEditor,
setShowRenderQueue,
setKnobs,
setResearchProvider,
setBudgetCap,
updateRenderJob,
initializeProject,
} = projectState;
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isResearching, setIsResearching] = useState(false);
const [announcement, setAnnouncement] = useState("");
const [showResumeAlert, setShowResumeAlert] = useState(false);
const [showPreflightDialog, setShowPreflightDialog] = useState(false);
const [preflightResponse, setPreflightResponse] = useState<any>(null);
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
const budgetTracking = useBudgetTracking(budgetCap || 50);
const preflightCheck = usePreflightCheck({
onBlocked: (response) => {
setPreflightResponse(response);
setShowPreflightDialog(true);
},
});
// Update budget cap when project state changes
useEffect(() => {
if (budgetCap) {
budgetTracking.setBudgetCap(budgetCap);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [budgetCap]);
// Check if we have a saved project on mount
useEffect(() => {
if (project && currentStep && currentStep !== "create") {
setShowResumeAlert(true);
setTimeout(() => setShowResumeAlert(false), 5000);
}
}, []); // Only on mount
useEffect(() => {
if (announcement) {
const t = setTimeout(() => setAnnouncement(""), 4000);
return () => clearTimeout(t);
}
return undefined;
}, [announcement]);
const handleCreate = useCallback(async (payload: CreateProjectPayload) => {
if (isAnalyzing) return;
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
setIsAnalyzing(true);
setAnnouncement("Analyzing your idea — AI suggestions incoming");
const result = await podcastApi.createProject(payload);
await initializeProject(payload, result.projectId);
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
setAnalysis(result.analysis);
setEstimate(result.estimate);
setQueries(result.queries);
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
setKnobs(payload.knobs);
setBudgetCap(payload.budgetCap);
setAnnouncement("Analysis complete");
} catch (error: any) {
if (error?.response?.status === 429 || error?.response?.data?.detail) {
const errorDetail = error.response.data.detail;
if (typeof errorDetail === 'object' && errorDetail.error && errorDetail.error.includes('limit')) {
const usageInfo = errorDetail.usage_info || {};
const blockedResponse = {
can_proceed: false,
estimated_cost: 0,
operations: [{
provider: errorDetail.provider || 'huggingface',
operation_type: 'ai_text_generation',
cost: 0,
allowed: false,
limit_info: usageInfo.limit_info || null,
message: errorDetail.message || errorDetail.error || 'Subscription limit exceeded',
}],
total_cost: 0,
usage_summary: usageInfo.usage_summary || null,
cached: false,
};
setPreflightResponse(blockedResponse);
setPreflightOperationName('Podcast Analysis');
setShowPreflightDialog(true);
setAnnouncement("Subscription limit reached. Please upgrade to continue.");
} else {
const message = typeof errorDetail === 'string' ? errorDetail : errorDetail.message || errorDetail.error || 'Request limit exceeded';
announceError(setAnnouncement, new Error(message));
}
} else {
announceError(setAnnouncement, error);
}
} finally {
setIsAnalyzing(false);
}
}, [isAnalyzing, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, initializeProject, setProject, setAnalysis, setEstimate, setQueries, setSelectedQueries, setKnobs, setBudgetCap]);
const handleRunResearch = useCallback(async () => {
if (isResearching) return;
if (!project) {
setAnnouncement("Create a project first.");
return;
}
if (selectedQueries.size === 0) {
setAnnouncement("Select at least one query to research.");
return;
}
setPreflightOperationName("Research");
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
const preflightResult = await preflightCheck.check({
provider: researchProvider === "exa" ? "exa" : "gemini",
operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding",
tokens_requested: researchProvider === "exa" ? 0 : 1200,
actual_provider_name: researchProvider || "exa",
});
if (!preflightResult.can_proceed) {
return;
}
try {
setIsResearching(true);
setAnnouncement(`Starting ${researchProvider === "exa" ? "deep" : "standard"} research — this may take a moment...`);
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
const { research: mapped, raw } = await podcastApi.runResearch({
projectId: project.id,
topic: project.idea,
approvedQueries,
provider: researchProvider,
exaConfig: sanitizeExaConfig(analysis?.exaSuggestedConfig),
onProgress: (message) => {
setAnnouncement(message);
},
});
setResearch(mapped);
setRawResearch(raw);
setAnnouncement("Research complete — review fact cards below");
} catch (researchError) {
const errorMessage = researchError instanceof Error
? researchError.message
: "Research failed. Please try again or switch to Standard Research.";
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
} else if (errorMessage.includes("timeout")) {
setAnnouncement("Research timed out. Please try again with fewer queries.");
} else {
setAnnouncement(`Research failed: ${errorMessage}`);
}
console.error("Research error:", researchError);
throw researchError;
}
} catch (error) {
announceError(setAnnouncement, error);
} finally {
setIsResearching(false);
}
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue]);
const handleGenerateScript = useCallback(async () => {
if (showScriptEditor) return;
if (!project || !research) {
setAnnouncement("Project or research missing — cannot generate script");
return;
}
setPreflightOperationName("Script Generation");
const preflightResult = await preflightCheck.check({
provider: "gemini",
operation_type: "script_generation",
tokens_requested: 2000,
actual_provider_name: "gemini",
});
if (!preflightResult.can_proceed) {
return;
}
setScriptData(null);
setShowRenderQueue(false);
setShowScriptEditor(true);
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor]);
const handleProceedToRendering = useCallback((script: Script) => {
setScriptData(script);
if (renderJobs.length === 0) {
script.scenes.forEach((scene) => {
const hasExistingAudio = Boolean(scene.audioUrl);
updateRenderJob(scene.id, {
sceneId: scene.id,
title: scene.title,
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
progress: hasExistingAudio ? 100 : 0,
previewUrl: null,
finalUrl: hasExistingAudio ? scene.audioUrl : null,
jobId: null,
});
});
}
setShowRenderQueue(true);
setShowScriptEditor(false);
}, [renderJobs.length, setScriptData, updateRenderJob, setShowRenderQueue, setShowScriptEditor]);
const toggleQuery = useCallback((id: string) => {
if (isResearching) return;
const current = selectedQueries;
const next = new Set<string>(current);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedQueries(next);
}, [isResearching, selectedQueries, setSelectedQueries]);
const activeStep = useMemo(() => {
if (showRenderQueue) return 3;
if (showScriptEditor) return 2;
if (research) return 1;
if (analysis) return 0;
return -1;
}, [showRenderQueue, showScriptEditor, research, analysis]);
const canGenerateScript = Boolean(project && research && rawResearch);
return {
// State
isAnalyzing,
isResearching,
announcement,
showResumeAlert,
showPreflightDialog,
preflightResponse,
preflightOperationName,
activeStep,
canGenerateScript,
// Handlers
handleCreate,
handleRunResearch,
handleGenerateScript,
handleProceedToRendering,
toggleQuery,
setAnnouncement,
setShowResumeAlert,
setShowPreflightDialog,
setPreflightResponse,
setResearchProvider,
getStepLabel,
};
};

View File

@@ -0,0 +1,76 @@
import { ResearchConfig, DateRange } from "../../../services/blogWriterApi";
import { CreateProjectPayload, Knobs } from "../types";
export const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
export const allowedDateRanges: DateRange[] = [
"last_week",
"last_month",
"last_3_months",
"last_6_months",
"last_year",
"all_time",
];
export const sanitizeExaConfig = (
exa?: CreateProjectPayload["knobs"] & any & { exa_suggested_config?: any } | any
): ResearchConfig | undefined => {
if (!exa) return undefined;
const cfg = exa as {
exa_search_type?: "auto" | "keyword" | "neural";
exa_category?: string;
exa_include_domains?: string[];
exa_exclude_domains?: string[];
max_sources?: number;
include_statistics?: boolean;
date_range?: string;
};
const searchType: ResearchConfig["exa_search_type"] =
cfg.exa_search_type && ["auto", "keyword", "neural"].includes(cfg.exa_search_type)
? cfg.exa_search_type
: undefined;
const dateRange: DateRange | undefined = cfg.date_range && allowedDateRanges.includes(cfg.date_range as DateRange)
? (cfg.date_range as DateRange)
: undefined;
return {
provider: "exa",
exa_search_type: searchType,
exa_category: cfg.exa_category,
exa_include_domains: cfg.exa_include_domains,
exa_exclude_domains: cfg.exa_exclude_domains,
max_sources: cfg.max_sources,
include_statistics: cfg.include_statistics,
date_range: dateRange,
};
};
export const announceError = (setAnnouncement: (msg: string) => void, error: unknown) => {
const message = error instanceof Error ? error.message : "Unexpected error";
setAnnouncement(message);
};
export const getStepLabel = (step: string | null): string => {
switch (step) {
case "analysis":
return "Analysis";
case "research":
return "Research";
case "script":
return "Script Editing";
case "render":
return "Rendering";
default:
return "Unknown";
}
};