Analyzing your idea with AI...

This commit is contained in:
ajaysi
2026-04-19 13:21:36 +05:30
parent e704aa7d87
commit 0732887c09
17 changed files with 6225 additions and 0 deletions

View File

@@ -0,0 +1,837 @@
import { ResearchProvider, ResearchConfig } from "./blogWriterApi";
import {
storyWriterApi,
StorySetupGenerationResponse,
} from "./storyWriterApi";
import { getResearchConfig, ResearchPersona } from "../api/researchConfig";
import { aiApiClient } from "../api/client";
import {
CreateProjectPayload,
CreateProjectResult,
Fact,
Knobs,
PodcastAnalysis,
PodcastEstimate,
Query,
RenderJobResult,
Research,
Scene,
Script,
} from "../components/PodcastMaker/types";
import { checkPreflight, PreflightOperation } from "./billingService";
import { TaskStatus } from "./storyWriterApi";
const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
// const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const createId = (prefix: string) => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return `${prefix}_${crypto.randomUUID()}`;
}
return `${prefix}_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
};
type OptionLike = StorySetupGenerationResponse["options"][0] | { plot_elements?: string; premise?: string };
const deriveSegments = (option?: OptionLike): string[] => {
const segments: string[] = [];
if (option?.plot_elements) {
option.plot_elements
.split(/[,.;]+/)
.map((p) => p.trim())
.filter(Boolean)
.forEach((p) => segments.push(p));
}
if (!segments.length && "premise" in (option || {}) && (option as any)?.premise) {
segments.push("Intro", "Key Takeaways", "Examples", "CTA");
}
return segments.slice(0, 5);
};
const estimateCosts = ({
minutes,
scenes,
chars,
quality,
avatars,
queryCount = 3,
}: {
minutes: number;
scenes: number;
chars: number;
quality: string;
avatars: number;
queryCount?: number;
}): 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);
return {
ttsCost: +ttsCost.toFixed(2),
avatarCost: +avatarCost.toFixed(2),
videoCost: +videoCost.toFixed(2),
researchCost,
total,
};
};
const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string): Query[] => {
const baseIdea = seed || "AI marketing for small businesses";
const personaKeywords = persona?.suggested_keywords?.filter(Boolean) || [];
const angles = persona?.research_angles ?? [];
const generated: Query[] = [];
const addQuery = (q: string, why: string, needsRecent = false) => {
if (!q.trim()) return;
generated.push({
id: createId("q"),
query: q.trim(),
rationale: why,
needsRecentStats: needsRecent,
});
};
if (personaKeywords.length) {
personaKeywords.slice(0, 4).forEach((k, idx) =>
addQuery(k, angles[idx % Math.max(1, angles.length)] || "Persona-aligned query", /202[45]|latest|trend/i.test(k))
);
}
if (!generated.length) {
addQuery(`How is ${baseIdea} evolving in 2024?`, "Trend + outcome focus", true);
addQuery(`Best practices for ${baseIdea}`, "Actionable guidance", false);
addQuery(`${baseIdea} case studies with ROI`, "Proof and outcomes", true);
addQuery(`${baseIdea} risks and objections`, "Address listener concerns", false);
}
return generated.slice(0, 6);
};
const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => {
if (!sources || !sources.length) return [];
return sources.slice(0, 12).map((source: ExaSource, idx: number) => ({
id: source.url || createId("fact"),
quote: source.excerpt || source.title || "Insight",
url: source.url || "",
date: source.published_at || "Unknown",
confidence: typeof (source as any).credibility_score === "number" ? (source as any).credibility_score : Math.max(0.5, 0.85 - idx * 0.02),
image: source.image,
author: source.author,
highlights: source.highlights,
}));
};
type ExaSource = {
title?: string;
url?: string;
excerpt?: string;
published_at?: string;
highlights?: string[];
summary?: string;
source_type?: string;
index?: number;
image?: string;
author?: string;
};
type ExaResearchResult = {
sources: ExaSource[];
search_queries?: string[];
cost?: { total?: number };
search_type?: string;
provider?: string;
content?: string;
};
const mapExaResearchResponse = (response: any): Research => {
const factCards = mapSourcesToFacts(response.sources);
// Use backend summary if available, otherwise use full content (no truncation) or fallback text
const summary = response.summary || response.content || "Research completed.";
const keyInsights = (response.key_insights || []).map((insight: any) => ({
title: insight.title || "Insight",
content: insight.content || "",
source_indices: insight.source_indices || []
}));
const expertQuotes = (response.expert_quotes || []).map((eq: any) => ({
quote: eq.quote || eq.text || "",
source_index: eq.source_index ?? 0
}));
const listenerCta = response.listener_cta || [];
const mappedAngles = (response.mapped_angles || []).map((angle: any) => ({
title: angle.title || "",
why: angle.why || angle.rationale || "",
mappedFactIds: angle.mapped_fact_ids || angle.mappedFactIds || []
}));
return {
summary,
keyInsights,
factCards,
mappedAngles,
expertQuotes,
listenerCta,
searchQueries: response.search_queries,
searchType: response.search_type,
provider: response.provider || "exa",
cost: response.cost?.total,
sourceCount: response.sources?.length || 0,
};
};
const ensurePreflight = async (operation: PreflightOperation) => {
const result = await checkPreflight(operation);
if (!result.can_proceed) {
const message = result.operations[0]?.message || "Pre-flight validation failed";
throw new Error(message);
}
return result;
};
export const podcastApi = {
async createProject(payload: CreateProjectPayload, bible?: any, feedback?: string): Promise<CreateProjectResult> {
const storyIdea = payload.ideaOrUrl || "AI marketing for small businesses";
await ensurePreflight({
provider: "gemini",
operation_type: "podcast_analysis",
tokens_requested: 1500,
actual_provider_name: "gemini",
});
// Podcast-specific analysis (not story setup)
const analysisResp = await aiApiClient.post("/api/podcast/analyze", {
idea: storyIdea,
duration: payload.duration,
speakers: payload.speakers,
bible: bible,
avatar_url: payload.avatarUrl,
feedback: feedback, // Pass feedback to backend
});
const outlines = (analysisResp.data?.suggested_outlines || []).map((o: any, idx: number) => ({
id: o.id || `outline-${idx + 1}`,
title: o.title || `Outline ${idx + 1}`,
segments: Array.isArray(o.segments) ? o.segments : deriveSegments({ plot_elements: o.segments }),
}));
const analysis: PodcastAnalysis = {
audience: analysisResp.data?.audience || "Growth-minded pros",
contentType: analysisResp.data?.content_type || "Podcast interview",
topKeywords: analysisResp.data?.top_keywords || outlines[0]?.segments?.slice(0, 3) || [],
suggestedOutlines: outlines,
suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs },
titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean),
research_queries: analysisResp.data?.research_queries || [],
exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined,
};
const researchConfig = await getResearchConfig().catch(() => null);
// Use AI-generated queries if available, fallback to legacy mapping
let queries: Query[] = [];
if (analysis.research_queries && analysis.research_queries.length > 0) {
queries = analysis.research_queries.map(rq => ({
id: createId("q"),
query: rq.query,
rationale: rq.rationale,
needsRecentStats: /202[45]|latest|trend/i.test(rq.query)
}));
} else {
queries = mapPersonaQueries(researchConfig?.research_persona, storyIdea);
}
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,
});
return {
projectId,
analysis,
estimate,
queries,
bible: analysisResp.data?.bible || undefined,
avatar_url: analysisResp.data?.avatar_url || null,
avatar_prompt: analysisResp.data?.avatar_prompt || null,
};
},
async enhanceIdea(params: { idea: string; bible?: any }): Promise<{ enhanced_ideas: string[]; rationales: string[] }> {
const response = await aiApiClient.post("/api/podcast/idea/enhance", params);
return response.data;
},
async runResearch(params: {
projectId: string;
topic: string;
approvedQueries: Query[];
provider?: ResearchProvider;
exaConfig?: ResearchConfig;
bible?: any;
analysis?: PodcastAnalysis | null;
onProgress?: (message: string) => void;
}): Promise<{ research: Research; raw: any }> {
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.");
}
// Ensure Exa payload respects API constraint: when requesting contents, only one of includeDomains or excludeDomains.
let sanitizedExaConfig: ResearchConfig | undefined = params.exaConfig;
if (sanitizedExaConfig && sanitizedExaConfig.exa_include_domains?.length) {
sanitizedExaConfig = {
...sanitizedExaConfig,
exa_exclude_domains: undefined,
};
} else if (sanitizedExaConfig && sanitizedExaConfig.exa_exclude_domains?.length) {
sanitizedExaConfig = {
...sanitizedExaConfig,
exa_include_domains: undefined,
};
}
await ensurePreflight({
provider: "exa",
operation_type: "exa_neural_search",
tokens_requested: 0,
actual_provider_name: "exa",
});
const response = await aiApiClient.post("/api/podcast/research/exa", {
topic: params.topic || keywords[0],
queries: keywords,
exa_config: sanitizedExaConfig,
bible: params.bible,
analysis: params.analysis,
});
const exaResult = response.data as ExaResearchResult;
if (params.onProgress) {
params.onProgress("Deep research completed with Exa.");
}
const mapped = mapExaResearchResponse(exaResult);
return { research: mapped, raw: exaResult };
},
async generateScript(params: {
projectId: string;
idea: string;
research?: ExaResearchResult | null;
knobs: Knobs;
speakers: number;
durationMinutes: number;
bible?: any;
outline?: any;
analysis?: PodcastAnalysis | null;
}): Promise<Script> {
await ensurePreflight({
provider: "gemini",
operation_type: "script_generation",
tokens_requested: 2000,
actual_provider_name: "gemini",
});
const response = await aiApiClient.post("/api/podcast/script", {
idea: params.idea,
duration_minutes: params.durationMinutes,
speakers: params.speakers,
research: params.research,
bible: params.bible,
outline: params.outline,
analysis: params.analysis,
});
const scenes = response.data?.scenes || [];
const scriptScenes: Scene[] = scenes.map((scene: any) => ({
id: scene.id || createId("scene"),
title: scene.title || "Scene",
duration: scene.duration || Math.max(20, params.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target),
lines:
Array.isArray(scene.lines) && scene.lines.length
? scene.lines.map((l: any) => ({
id: createId("line"),
speaker: l.speaker || "Host",
text: l.text || "",
}))
: [
{
id: createId("line"),
speaker: "Host",
text: "Let's dive into today's topic.",
},
],
approved: false,
}));
return { scenes: scriptScenes };
},
async previewLine(
text: string,
options: { voiceId?: string; speed?: number; emotion?: string } = {}
): Promise<{ ok: boolean; message: string; audioUrl?: string }> {
await ensurePreflight({
provider: "audio",
operation_type: "tts_preview",
tokens_requested: text.length,
actual_provider_name: "wavespeed",
});
const response = await storyWriterApi.generateAIAudio({
scene_number: 0,
scene_title: "Preview",
text,
voice_id: options.voiceId || "Wise_Woman",
speed: options.speed || 1.0,
emotion: options.emotion || "neutral",
});
if (!response.success) {
throw new Error(response.error || "Preview failed");
}
return {
ok: true,
message: "Preview ready opening audio in new tab.",
audioUrl: response.audio_url,
};
},
async renderSceneAudio(params: {
scene: Scene;
voiceId?: string;
emotion?: string; // Fallback if scene doesn't have emotion
speed?: number;
volume?: number;
pitch?: number;
englishNormalization?: boolean;
sampleRate?: number;
bitrate?: number;
channel?: "1" | "2";
format?: "mp3" | "wav" | "pcm" | "flac";
languageBoost?: string;
}): Promise<RenderJobResult> {
// Use scene-specific emotion if available, otherwise fallback to provided/default
const sceneEmotion = params.scene.emotion || params.emotion || "neutral";
// Optimize text for Minimax Speech-02-HD TTS
// - Strip markdown formatting (bold, italic, etc.) - TTS reads it literally
// - Use pause markers <#x#> for natural speech rhythm
// - Add longer pauses for speaker changes
// - Preserve punctuation for natural breathing
// - Add emphasis pauses for important points
const text = params.scene.lines
.map((line, idx) => {
let lineText = line.text.trim();
// Strip markdown formatting - TTS reads asterisks and other markdown literally
// Remove bold (**text** or __text__)
lineText = lineText.replace(/\*\*([^*]+)\*\*/g, '$1'); // **bold**
lineText = lineText.replace(/\*([^*]+)\*/g, '$1'); // *bold* (single asterisk)
lineText = lineText.replace(/__([^_]+)__/g, '$1'); // __bold__
lineText = lineText.replace(/_([^_]+)_/g, '$1'); // _italic_ (single underscore)
// Remove any remaining stray asterisks or underscores
lineText = lineText.replace(/\*+/g, ''); // Remove any remaining asterisks
lineText = lineText.replace(/_+/g, ''); // Remove any remaining underscores
// Clean up extra spaces
lineText = lineText.replace(/\s+/g, ' ').trim();
// Preserve punctuation (Minimax uses it for natural breathing)
// Don't strip punctuation - it helps TTS understand natural pauses
// Add emphasis pause after lines marked with emphasis
if (line.emphasis) {
// Minimal pause after emphasized content (0.15s for subtle emphasis)
lineText = `${lineText}<#0.15#>`;
}
// Check for speaker change (longer pause for natural conversation flow)
const prevLine = idx > 0 ? params.scene.lines[idx - 1] : null;
const isSpeakerChange = prevLine && prevLine.speaker !== line.speaker;
if (isSpeakerChange) {
// Short pause for speaker changes (0.2s - enough for natural transition)
lineText = `<#0.2#>${lineText}`;
}
// Add minimal pause between lines (only between regular lines, very short)
if (idx < params.scene.lines.length - 1) {
if (!line.emphasis && !isSpeakerChange) {
// Very short pause between lines (0.08s - barely noticeable but helps flow)
lineText = `${lineText}<#0.08#>`;
}
// If emphasis or speaker change, the pause is already added above
}
return lineText;
})
.join(" ");
// Validate character limit (Minimax max: 10,000 characters)
const MAX_CHARS = 10000;
let textToUse = text;
if (text.length > MAX_CHARS) {
console.warn(
`[Podcast] Scene "${params.scene.title}" exceeds ${MAX_CHARS} character limit (${text.length} chars). Truncating...`
);
// Truncate at word boundary to avoid cutting mid-word
const truncated = text.substring(0, MAX_CHARS);
const lastSpace = truncated.lastIndexOf(" ");
textToUse = lastSpace > 0 ? truncated.substring(0, lastSpace) : truncated;
}
await ensurePreflight({
provider: "audio",
operation_type: "tts_full_render",
tokens_requested: textToUse.length,
actual_provider_name: "wavespeed",
});
const response = await aiApiClient.post("/api/podcast/audio", {
scene_id: params.scene.id,
scene_title: params.scene.title,
text: textToUse,
voice_id: params.voiceId || "Wise_Woman",
speed: params.speed ?? 1.0, // Normal speed (was 0.9, but too slow - causing duration issues)
volume: params.volume ?? 1.0,
pitch: params.pitch ?? 0.0,
emotion: sceneEmotion,
english_normalization: params.englishNormalization ?? true, // Better number reading for statistics
sample_rate: params.sampleRate || null,
bitrate: params.bitrate || null,
channel: params.channel || null,
format: params.format || null,
language_boost: params.languageBoost || null,
});
return {
audioUrl: response.data.audio_url,
audioFilename: response.data.audio_filename,
provider: response.data.provider,
model: response.data.model,
cost: response.data.cost,
voiceId: response.data.voice_id,
fileSize: response.data.file_size,
};
},
async approveScene(params: { projectId: string; sceneId: string; notes?: string }) {
await aiApiClient.post("/api/story/script/approve", {
project_id: params.projectId,
scene_id: params.sceneId,
approved: true,
notes: params.notes,
});
},
// Project persistence endpoints
async saveProject(projectId: string, state: any): Promise<void> {
try {
await aiApiClient.put(`/api/podcast/projects/${projectId}`, state);
} catch (error) {
console.error("Failed to save project to database:", error);
// Don't throw - localStorage fallback is acceptable
}
},
async loadProject(projectId: string): Promise<any> {
const response = await aiApiClient.get(`/api/podcast/projects/${projectId}`);
return response.data;
},
async listProjects(params?: {
status?: string;
favorites_only?: boolean;
limit?: number;
offset?: number;
order_by?: "updated_at" | "created_at";
}): Promise<{ projects: any[]; total: number; limit: number; offset: number }> {
const response = await aiApiClient.get("/api/podcast/projects", { params });
return response.data;
},
async createProjectInDb(params: {
project_id: string;
idea: string;
duration: number;
speakers: number;
budget_cap: number;
avatar_url?: string | null;
}): Promise<any> {
const response = await aiApiClient.post("/api/podcast/projects", params);
return response.data;
},
async updateProject(projectId: string, updates: any): Promise<any> {
const response = await aiApiClient.put(`/api/podcast/projects/${projectId}`, updates);
return response.data;
},
async deleteProject(projectId: string): Promise<void> {
await aiApiClient.delete(`/api/podcast/projects/${projectId}`);
},
async toggleFavorite(projectId: string): Promise<any> {
const response = await aiApiClient.post(`/api/podcast/projects/${projectId}/favorite`);
return response.data;
},
async saveAudioToAssetLibrary(params: {
audioUrl: string;
filename: string;
title: string;
description?: string;
projectId: string;
sceneId?: string;
cost?: number;
provider?: string;
model?: string;
fileSize?: number;
}): Promise<{ assetId: number }> {
const response = await aiApiClient.post("/api/content-assets/", {
asset_type: "audio",
source_module: "podcast_maker",
filename: params.filename,
file_url: params.audioUrl,
title: params.title,
description: params.description || `Podcast episode audio: ${params.title}`,
tags: ["podcast", "audio", params.projectId],
asset_metadata: {
project_id: params.projectId,
scene_id: params.sceneId,
provider: params.provider,
model: params.model,
},
provider: params.provider,
model: params.model,
cost: params.cost || 0,
file_size: params.fileSize,
mime_type: "audio/mpeg",
});
return { assetId: response.data.id };
},
async generateVideo(params: {
projectId: string;
sceneId: string;
sceneTitle: string;
audioUrl: string;
avatarImageUrl?: string;
bible?: any;
resolution?: string;
prompt?: string;
seed?: number;
maskImageUrl?: string;
}): Promise<{ taskId: string; status: string; message: string }> {
const response = await aiApiClient.post("/api/podcast/render/video", {
project_id: params.projectId,
scene_id: params.sceneId,
scene_title: params.sceneTitle,
audio_url: params.audioUrl,
avatar_image_url: params.avatarImageUrl,
bible: params.bible,
resolution: params.resolution || "720p",
prompt: params.prompt,
seed: params.seed ?? -1,
mask_image_url: params.maskImageUrl,
});
// Backend returns snake_case (task_id); normalize to camelCase for callers
const { task_id, status, message } = response.data || {};
return {
taskId: task_id,
status,
message,
};
},
async pollTaskStatus(taskId: string): Promise<TaskStatus | null> {
const response = await aiApiClient.get(`/api/podcast/task/${taskId}/status`);
// Backend returns null if task not found
return response.data || null;
},
async listVideos(projectId?: string): Promise<{
videos: Array<{
scene_number: number;
filename: string;
video_url: string;
file_size: number;
}>;
}> {
const params = projectId ? { project_id: projectId } : {};
const response = await aiApiClient.get("/api/podcast/videos", { params });
return response.data;
},
async combineVideos(params: {
projectId: string;
sceneVideoUrls: string[];
podcastTitle?: string;
}): Promise<{
taskId: string;
status: string;
message: string;
}> {
const response = await aiApiClient.post("/api/podcast/render/combine-videos", {
project_id: params.projectId,
scene_video_urls: params.sceneVideoUrls,
podcast_title: params.podcastTitle || "Podcast",
});
const { task_id, status, message } = response.data || {};
return {
taskId: task_id,
status,
message,
};
},
async generateSceneImage(params: {
sceneId: string;
sceneTitle: string;
sceneContent?: string;
baseAvatarUrl?: string;
bible?: any;
idea?: string;
width?: number;
height?: number;
customPrompt?: string;
style?: "Auto" | "Fiction" | "Realistic";
renderingSpeed?: "Default" | "Turbo" | "Quality";
aspectRatio?: "1:1" | "16:9" | "9:16" | "4:3" | "3:4";
}): Promise<{
scene_id: string;
scene_title: string;
image_filename: string;
image_url: string;
width: number;
height: number;
provider: string;
model?: string;
cost: number;
}> {
const response = await aiApiClient.post("/api/podcast/image", {
scene_id: params.sceneId,
scene_title: params.sceneTitle,
scene_content: params.sceneContent,
base_avatar_url: params.baseAvatarUrl || null,
bible: params.bible,
idea: params.idea || null,
width: params.width || 1024,
height: params.height || 1024,
custom_prompt: params.customPrompt || null,
style: params.style || null,
rendering_speed: params.renderingSpeed || null,
aspect_ratio: params.aspectRatio || null,
});
return response.data;
},
async cancelTask(taskId: string): Promise<void> {
// Note: Task cancellation may not be fully supported by backend yet
// This is a placeholder for future implementation
try {
await aiApiClient.post(`/api/story/task/${taskId}/cancel`);
} catch (error) {
console.warn("Task cancellation not supported:", error);
}
},
async combineAudio(params: {
projectId: string;
sceneIds: string[];
sceneAudioUrls: string[];
}): Promise<{
combined_audio_url: string;
combined_audio_filename: string;
total_duration: number;
file_size: number;
scene_count: number;
}> {
const response = await aiApiClient.post("/api/podcast/combine-audio", {
project_id: params.projectId,
scene_ids: params.sceneIds,
scene_audio_urls: params.sceneAudioUrls,
});
return response.data;
},
async uploadAvatar(file: File, projectId?: string): Promise<{ avatar_url: string; avatar_filename: string }> {
const formData = new FormData();
formData.append('file', file);
if (projectId) {
formData.append('project_id', projectId);
}
const response = await aiApiClient.post('/api/podcast/avatar/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
async generatePresenters(
speakers: number,
projectId?: string,
audience?: string,
contentType?: string,
topKeywords?: string[]
): Promise<{
avatars: Array<{ avatar_url: string; speaker_number: number; prompt?: string; persona_id?: string; seed?: number }>;
persona_id?: string;
}> {
const formData = new FormData();
formData.append('speakers', speakers.toString());
if (projectId) {
formData.append('project_id', projectId);
}
if (audience) {
formData.append('audience', audience);
}
if (contentType) {
formData.append('content_type', contentType);
}
if (topKeywords && Array.isArray(topKeywords) && topKeywords.length > 0) {
formData.append('top_keywords', JSON.stringify(topKeywords));
}
const response = await aiApiClient.post('/api/podcast/avatar/generate', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
async makeAvatarPresentable(avatarUrl: string, projectId?: string): Promise<{ avatar_url: string; avatar_filename: string }> {
const formData = new FormData();
formData.append('avatar_url', avatarUrl);
if (projectId) {
formData.append('project_id', projectId);
}
const response = await aiApiClient.post('/api/podcast/avatar/make-presentable', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
};
export type PodcastApi = typeof podcastApi;