feat: podcast demo mode with ALWRITY_ENABLED_FEATURES support

- Add ALWRITY_ENABLED_FEATURES env var for feature gating
- Podcast-only mode: skip LLM bootstrap, scheduler, persona services
- Enhance video generation prompt with scene context, analysis, narration
- Add voice cloning support via custom_voice_id in WaveSpeed
- Add text-to-speech for research results (browser speechSynthesis)
- Fix render queue to sync images from script phase
- Add WaveSpeed LLM pricing (gpt-oss-120b)
- Fix podcast bible generation error handling
- Refactor RouterManager for feature-based router loading
This commit is contained in:
ajaysi
2026-04-03 06:59:59 +05:30
parent c52b1eabc9
commit 63bb937796
58 changed files with 3568 additions and 1597 deletions

View File

@@ -24,6 +24,8 @@ import { TaskStatus } from "./storyWriterApi";
const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
voice_id: "Wise_Woman",
custom_voice_id: undefined,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
@@ -119,31 +121,46 @@ const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string):
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;
publishedDate?: string; // Exa format
highlights?: string[];
summary?: string;
source_type?: string;
index?: number;
image?: string;
author?: string;
text?: string; // Exa full text content
credibility_score?: number;
};
const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => {
if (!sources || !sources.length) return [];
// Deduplicate by URL
const seenUrls = new Set<string>();
const uniqueSources = sources.filter(s => {
if (!s.url || seenUrls.has(s.url)) return false;
seenUrls.add(s.url);
return true;
});
return uniqueSources.slice(0, 12).map((source: ExaSource, idx: number) => ({
id: source.url || `fact-${idx}`,
quote: source.excerpt || source.highlights?.[0] || source.summary || source.title || "Insight",
url: source.url || "",
// Use published_at (backend format) or publishedDate (Exa format)
date: source.published_at || source.publishedDate || "Unknown",
confidence: source.credibility_score || Math.max(0.5, 0.85 - idx * 0.02),
image: source.image,
author: source.author,
highlights: source.highlights,
// Include full text if available
fullText: source.text,
}));
};
type ExaResearchResult = {
@@ -180,7 +197,9 @@ const mapExaResearchResponse = (response: any): Research => {
};
const ensurePreflight = async (operation: PreflightOperation) => {
console.log('[podcastApi] Running preflight for:', operation);
const result = await checkPreflight(operation);
console.log('[podcastApi] Preflight result:', result);
if (!result.can_proceed) {
const message = result.operations[0]?.message || "Pre-flight validation failed";
throw new Error(message);
@@ -222,6 +241,10 @@ export const podcastApi = {
suggestedOutlines: outlines,
suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs },
titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean),
episode_hook: analysisResp.data?.episode_hook || "",
key_takeaways: analysisResp.data?.key_takeaways || [],
guest_talking_points: analysisResp.data?.guest_talking_points || [],
listener_cta: analysisResp.data?.listener_cta || "",
research_queries: analysisResp.data?.research_queries || [],
exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined,
};
@@ -241,6 +264,9 @@ export const podcastApi = {
queries = mapPersonaQueries(researchConfig?.research_persona, storyIdea);
}
// Note: selectedQueries should be set to empty Set by the caller (workflow)
// so users can manually choose which queries to run
const projectId = createId("podcast");
const estimate = estimateCosts({
minutes: payload.duration,
@@ -303,13 +329,20 @@ export const podcastApi = {
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,
});
let response;
try {
response = await aiApiClient.post("/api/podcast/research/exa", {
topic: params.topic || keywords[0],
queries: keywords,
exa_config: sanitizedExaConfig,
bible: params.bible,
analysis: params.analysis,
}, { timeout: 300000 }); // 5 minute timeout for research
console.log('[podcastApi] Exa research response received:', response.status, response.data);
} catch (error: any) {
console.error('[podcastApi] Exa research error:', error?.response?.status, error?.response?.data, error?.message);
throw error;
}
const exaResult = response.data as ExaResearchResult;
if (params.onProgress) {
@@ -329,6 +362,7 @@ export const podcastApi = {
bible?: any;
outline?: any;
analysis?: PodcastAnalysis | null;
onProgress?: (message: string) => void;
}): Promise<Script> {
await ensurePreflight({
provider: "gemini",
@@ -337,6 +371,10 @@ export const podcastApi = {
actual_provider_name: "gemini",
});
if (params.onProgress) {
params.onProgress("Analyzing research data and extracting key insights...");
}
const response = await aiApiClient.post("/api/podcast/script", {
idea: params.idea,
duration_minutes: params.durationMinutes,
@@ -347,6 +385,10 @@ export const podcastApi = {
analysis: params.analysis,
});
if (params.onProgress) {
params.onProgress("Creating podcast structure with scenes and dialogue...");
}
const scenes = response.data?.scenes || [];
const scriptScenes: Scene[] = scenes.map((scene: any) => ({
id: scene.id || createId("scene"),
@@ -406,6 +448,7 @@ export const podcastApi = {
async renderSceneAudio(params: {
scene: Scene;
voiceId?: string;
customVoiceId?: string;
emotion?: string; // Fallback if scene doesn't have emotion
speed?: number;
volume?: number;
@@ -498,6 +541,7 @@ export const podcastApi = {
scene_title: params.scene.title,
text: textToUse,
voice_id: params.voiceId || "Wise_Woman",
custom_voice_id: params.customVoiceId || null,
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,
@@ -522,7 +566,7 @@ export const podcastApi = {
},
async approveScene(params: { projectId: string; sceneId: string; notes?: string }) {
await aiApiClient.post("/api/story/script/approve", {
await aiApiClient.post("/api/podcast/script/approve", {
project_id: params.projectId,
scene_id: params.sceneId,
approved: true,
@@ -564,8 +608,22 @@ export const podcastApi = {
budget_cap: number;
avatar_url?: string | null;
}): Promise<any> {
const response = await aiApiClient.post("/api/podcast/projects", params);
return response.data;
try {
const response = await aiApiClient.post("/api/podcast/projects", params);
return response.data;
} catch (error: any) {
if (error?.response?.status === 409) {
// Duplicate idea detected - throw specific error for UI handling
const conflictData = error.response.data?.detail || {};
throw new Error(JSON.stringify({
type: "DUPLICATE_IDEA",
existing_project_id: conflictData.existing_project_id,
existing_idea: conflictData.existing_idea,
message: conflictData.message,
}));
}
throw error;
}
},
async updateProject(projectId: string, updates: any): Promise<any> {
@@ -582,6 +640,16 @@ export const podcastApi = {
return response.data;
},
async regenerateResearchQueries(params: {
idea: string;
feedback: string;
existing_analysis?: any;
bible?: any;
}): Promise<{ research_queries: { query: string; rationale: string }[] }> {
const response = await aiApiClient.post("/api/podcast/regenerate-queries", params);
return response.data;
},
async saveAudioToAssetLibrary(params: {
audioUrl: string;
filename: string;
@@ -624,6 +692,9 @@ export const podcastApi = {
audioUrl: string;
avatarImageUrl?: string;
bible?: any;
analysis?: any;
sceneImagePrompt?: string;
sceneNarration?: string;
resolution?: string;
prompt?: string;
seed?: number;
@@ -636,6 +707,9 @@ export const podcastApi = {
audio_url: params.audioUrl,
avatar_image_url: params.avatarImageUrl,
bible: params.bible,
analysis: params.analysis,
scene_image_prompt: params.sceneImagePrompt,
scene_narration: params.sceneNarration,
resolution: params.resolution || "720p",
prompt: params.prompt,
seed: params.seed ?? -1,
@@ -697,9 +771,15 @@ export const podcastApi = {
sceneId: string;
sceneTitle: string;
sceneContent?: string;
sceneEmotion?: string;
baseAvatarUrl?: string;
bible?: any;
idea?: string;
analysis?: {
audience?: string;
contentType?: string;
topKeywords?: string[];
};
width?: number;
height?: number;
customPrompt?: string;
@@ -716,14 +796,17 @@ export const podcastApi = {
provider: string;
model?: string;
cost: number;
image_prompt?: string;
}> {
const response = await aiApiClient.post("/api/podcast/image", {
scene_id: params.sceneId,
scene_title: params.sceneTitle,
scene_content: params.sceneContent,
scene_emotion: params.sceneEmotion || null,
base_avatar_url: params.baseAvatarUrl || null,
bible: params.bible,
idea: params.idea || null,
analysis: params.analysis || null,
width: params.width || 1024,
height: params.height || 1024,
custom_prompt: params.customPrompt || null,