feat: voice clone audio generation + podcast workspace architecture

- Voice clone integration: When user selects voice clone in Write phase,
  backend uses their uploaded voice sample + scene script text to generate
  audio via qwen3/minimax/cosyvoice voice clone APIs
- Multi-tenant workspace storage: All podcast assets (audio, video, images,
  charts) now use workspace-specific directories per user
- Chart preview improvements: Card-based B-Roll charts UI with thumbnails,
  takeaway text, and action buttons; public endpoint for image serving
- Voice clone caching: In-memory LRU cache for voice samples (avoids
  re-downloading per scene); frontend caches voice clone metadata
- Thread pool for voice clone: Audio generation uses ThreadPoolExecutor to
  avoid blocking the FastAPI event loop
- Auto-detect voice clone IDs (vc_*, MY_VOICE_CLONE) to route correctly
- DB fallback for voice sample URL: Fetches from ContentAsset if not passed
- Fixed API URL resolution for chart previews
- Fixed GlassyCard DOM warnings for motion props
- Fixed ScriptGenerationProgressView syntax error
- Fixed usePodcastWorkflow scriptData reference
This commit is contained in:
ajaysi
2026-04-21 19:38:50 +05:30
parent 7637babd7d
commit 91b2f996fd
33 changed files with 1642 additions and 457 deletions

View File

@@ -60,6 +60,9 @@ export interface PodcastProjectState {
// Backend project creation status — prevents 404 sync calls before project exists
backendProjectCreated?: boolean;
// Track last synced phase to prevent duplicate syncs
lastSyncedPhase?: string | null;
}
const DEFAULT_KNOBS: Knobs = {
@@ -162,21 +165,28 @@ export const usePodcastProjectState = () => {
}
}, [state]);
// Sync to database after major steps (debounced)
// Sync to database ONLY on phase transitions (not on every state change)
// This ensures we sync at: Create → Analyze → Research → Script → Render
useEffect(() => {
if (!state.project || !state.project.id || !state.backendProjectCreated) return;
if (!state.currentStep) return;
// Skip if already synced this phase (handles duplicate calls from handleCreate/etc)
if (state.currentStep === state.lastSyncedPhase) {
return;
}
// Capture project ID to avoid closure issues
const projectId = state.project.id;
// Clear existing timeout
// Debounce - wait for state to settle before syncing
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
// Debounce database sync (wait 2 seconds after last change)
syncTimeoutRef.current = setTimeout(async () => {
try {
console.log(`[Sync] Saving project at phase: ${state.currentStep}`);
const dbState = {
analysis: state.analysis,
queries: state.queries,
@@ -195,39 +205,37 @@ export const usePodcastProjectState = () => {
status: state.currentStep === 'render' && state.renderJobs.every(j => j.status === 'completed') ? 'completed' : 'in_progress',
};
await podcastApi.saveProject(projectId, dbState);
const saved = await podcastApi.saveProject(projectId, dbState);
if (saved) {
setState((prev) => ({ ...prev, lastSyncedPhase: prev.currentStep }));
console.log(`[Sync] Project saved successfully at phase: ${state.currentStep}`);
} else {
console.warn(`[Sync] Failed to save project at phase: ${state.currentStep} - will retry on next phase change`);
}
} catch (error) {
console.error('Error syncing project to database:', error);
// Don't throw - localStorage is still working
console.error('[Sync] Error saving project:', error);
}
}, 2000);
}, 1500);
return () => {
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
};
}, [
state.project,
state.analysis,
state.queries,
state.selectedQueries,
state.research,
state.rawResearch,
state.estimate,
state.scriptData,
state.renderJobs,
state.knobs,
state.bible,
state.researchProvider,
state.showScriptEditor,
state.showRenderQueue,
state.currentStep,
]);
// Only sync when phase changes - not on every state field change
}, [state.currentStep, state.backendProjectCreated]);
// Setters
const setProject = useCallback((project: PodcastProjectState['project']) => {
setState((prev) => ({ ...prev, project, currentStep: project ? 'analysis' : null, updatedAt: new Date().toISOString() }));
const newStep = project ? 'analysis' : null;
setState((prev) => ({
...prev,
project,
currentStep: newStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString()
}));
}, []);
const setAnalysis = useCallback((analysis: PodcastProjectState['analysis']) => {
@@ -235,6 +243,7 @@ export const usePodcastProjectState = () => {
...prev,
analysis,
currentStep: analysis ? 'research' : prev.currentStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString()
}));
}, []);
@@ -255,6 +264,7 @@ export const usePodcastProjectState = () => {
...prev,
research,
currentStep: research ? 'script' : prev.currentStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString()
}));
}, []);
@@ -272,6 +282,7 @@ export const usePodcastProjectState = () => {
...prev,
scriptData,
currentStep: scriptData ? 'render' : prev.currentStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString()
}));
}, []);