Files
ALwrity/frontend/src/hooks/usePodcastProjectState.ts
2025-12-16 16:25:52 +05:30

422 lines
13 KiB
TypeScript

import { useState, useCallback, useEffect, useRef } from 'react';
import {
PodcastAnalysis,
PodcastEstimate,
Query,
Research,
Script,
Knobs,
Job,
CreateProjectPayload,
} from '../components/PodcastMaker/types';
import { BlogResearchResponse, ResearchProvider } from '../services/blogWriterApi';
import { podcastApi } from '../services/podcastApi';
export interface PodcastProjectState {
// Project metadata
project: {
id: string;
idea: string;
duration: number;
speakers: number;
avatarUrl?: string | null;
avatarPrompt?: string | null;
avatarPersonaId?: string | null;
} | null;
// Step results
analysis: PodcastAnalysis | null;
queries: Query[];
selectedQueries: Set<string>;
research: Research | null;
rawResearch: BlogResearchResponse | null;
estimate: PodcastEstimate | null;
scriptData: Script | null;
// Render jobs
renderJobs: Job[];
// Settings
knobs: Knobs;
researchProvider: ResearchProvider;
budgetCap: number;
// UI state
showScriptEditor: boolean;
showRenderQueue: boolean;
// Current step tracking
currentStep: 'create' | 'analysis' | 'research' | 'script' | 'render' | null;
// Final combined video
finalVideoUrl?: string | null;
// Timestamps
createdAt?: string;
updatedAt?: string;
}
const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
const DEFAULT_STATE: PodcastProjectState = {
project: null,
analysis: null,
queries: [],
selectedQueries: new Set(),
research: null,
rawResearch: null,
estimate: null,
scriptData: null,
renderJobs: [],
knobs: DEFAULT_KNOBS,
researchProvider: "exa",
budgetCap: 50,
showScriptEditor: false,
showRenderQueue: false,
currentStep: null,
};
const STORAGE_KEY = 'podcast_project_state';
export const usePodcastProjectState = () => {
const [state, setState] = useState<PodcastProjectState>(() => {
// Initialize from localStorage if available
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
// Restore Sets from arrays
const restoredState: PodcastProjectState = {
...DEFAULT_STATE,
...parsed,
selectedQueries: parsed.selectedQueries ? new Set(parsed.selectedQueries) : new Set(),
renderJobs: parsed.renderJobs || [],
};
return restoredState;
}
} catch (error) {
console.error('Error loading podcast project state from localStorage:', error);
}
return DEFAULT_STATE;
});
// Debounce ref for database sync
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Persist state to localStorage on every change
useEffect(() => {
try {
// Convert Sets to arrays for JSON serialization
const serializableState = {
...state,
selectedQueries: Array.from(state.selectedQueries),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(serializableState));
} catch (error) {
console.error('Error saving podcast project state to localStorage:', error);
}
}, [state]);
// Sync to database after major steps (debounced)
useEffect(() => {
if (!state.project || !state.project.id) return;
// Capture project ID to avoid closure issues
const projectId = state.project.id;
// Clear existing timeout
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
// Debounce database sync (wait 2 seconds after last change)
syncTimeoutRef.current = setTimeout(async () => {
try {
const dbState = {
analysis: state.analysis,
queries: state.queries,
selected_queries: Array.from(state.selectedQueries),
research: state.research,
raw_research: state.rawResearch,
estimate: state.estimate,
script_data: state.scriptData,
render_jobs: state.renderJobs,
knobs: state.knobs,
research_provider: state.researchProvider,
show_script_editor: state.showScriptEditor,
show_render_queue: state.showRenderQueue,
current_step: state.currentStep,
status: state.currentStep === 'render' && state.renderJobs.every(j => j.status === 'completed') ? 'completed' : 'in_progress',
};
await podcastApi.saveProject(projectId, dbState);
} catch (error) {
console.error('Error syncing project to database:', error);
// Don't throw - localStorage is still working
}
}, 2000);
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.researchProvider,
state.showScriptEditor,
state.showRenderQueue,
state.currentStep,
]);
// Setters
const setProject = useCallback((project: PodcastProjectState['project']) => {
setState((prev) => ({ ...prev, project, currentStep: project ? 'analysis' : null, updatedAt: new Date().toISOString() }));
}, []);
const setAnalysis = useCallback((analysis: PodcastProjectState['analysis']) => {
setState((prev) => ({
...prev,
analysis,
currentStep: analysis ? 'research' : prev.currentStep,
updatedAt: new Date().toISOString()
}));
}, []);
const setQueries = useCallback((queries: Query[]) => {
setState((prev) => ({ ...prev, queries, updatedAt: new Date().toISOString() }));
}, []);
const setSelectedQueries = useCallback((selectedQueries: Set<string> | ((prev: Set<string>) => Set<string>)) => {
setState((prev) => {
const newQueries = typeof selectedQueries === 'function' ? selectedQueries(prev.selectedQueries) : selectedQueries;
return { ...prev, selectedQueries: newQueries, updatedAt: new Date().toISOString() };
});
}, []);
const setResearch = useCallback((research: PodcastProjectState['research']) => {
setState((prev) => ({
...prev,
research,
currentStep: research ? 'script' : prev.currentStep,
updatedAt: new Date().toISOString()
}));
}, []);
const setRawResearch = useCallback((rawResearch: PodcastProjectState['rawResearch']) => {
setState((prev) => ({ ...prev, rawResearch, updatedAt: new Date().toISOString() }));
}, []);
const setEstimate = useCallback((estimate: PodcastProjectState['estimate']) => {
setState((prev) => ({ ...prev, estimate, updatedAt: new Date().toISOString() }));
}, []);
const setScriptData = useCallback((scriptData: PodcastProjectState['scriptData']) => {
setState((prev) => ({
...prev,
scriptData,
currentStep: scriptData ? 'render' : prev.currentStep,
updatedAt: new Date().toISOString()
}));
}, []);
const setRenderJobs = useCallback((renderJobs: Job[]) => {
setState((prev) => ({ ...prev, renderJobs, updatedAt: new Date().toISOString() }));
}, []);
const updateRenderJob = useCallback((sceneId: string, updates: Partial<Job>) => {
setState((prev) => {
const existingJob = prev.renderJobs.find((job) => job.sceneId === sceneId);
if (existingJob) {
// Update existing job
return {
...prev,
renderJobs: prev.renderJobs.map((job) =>
job.sceneId === sceneId ? { ...job, ...updates } : job
),
updatedAt: new Date().toISOString(),
};
} else {
// Create new job if it doesn't exist
const newJob: Job = {
sceneId,
title: updates.title || sceneId,
status: updates.status || "idle",
progress: updates.progress || 0,
previewUrl: updates.previewUrl || null,
finalUrl: updates.finalUrl || null,
videoUrl: updates.videoUrl || null,
imageUrl: updates.imageUrl || null,
jobId: updates.jobId || null,
taskId: updates.taskId || null,
cost: updates.cost || null,
provider: updates.provider || null,
voiceId: updates.voiceId || null,
fileSize: updates.fileSize || null,
avatarImageUrl: updates.avatarImageUrl || null,
};
return {
...prev,
renderJobs: [...prev.renderJobs, newJob],
updatedAt: new Date().toISOString(),
};
}
});
}, []);
const setKnobs = useCallback((knobs: Knobs) => {
setState((prev) => ({ ...prev, knobs, updatedAt: new Date().toISOString() }));
}, []);
const setResearchProvider = useCallback((provider: ResearchProvider) => {
setState((prev) => ({ ...prev, researchProvider: provider, updatedAt: new Date().toISOString() }));
}, []);
const setBudgetCap = useCallback((cap: number) => {
setState((prev) => ({ ...prev, budgetCap: cap, updatedAt: new Date().toISOString() }));
}, []);
const setShowScriptEditor = useCallback((show: boolean) => {
setState((prev) => ({ ...prev, showScriptEditor: show, updatedAt: new Date().toISOString() }));
}, []);
const setShowRenderQueue = useCallback((show: boolean) => {
setState((prev) => ({ ...prev, showRenderQueue: show, updatedAt: new Date().toISOString() }));
}, []);
const setCurrentStep = useCallback((step: PodcastProjectState['currentStep']) => {
setState((prev) => ({ ...prev, currentStep: step, updatedAt: new Date().toISOString() }));
}, []);
// Reset state
const resetState = useCallback(() => {
setState(DEFAULT_STATE);
localStorage.removeItem(STORAGE_KEY);
}, []);
// Initialize project from payload
const initializeProject = useCallback(async (payload: CreateProjectPayload, projectId: string) => {
// Create project in database
try {
await podcastApi.createProjectInDb({
project_id: projectId,
idea: payload.ideaOrUrl,
duration: payload.duration,
speakers: payload.speakers,
budget_cap: payload.budgetCap,
});
} catch (error) {
console.error('Error creating project in database:', error);
// Continue anyway - localStorage fallback
}
setState((prev) => ({
...prev,
project: {
id: projectId,
idea: payload.ideaOrUrl,
duration: payload.duration,
speakers: payload.speakers,
avatarUrl: payload.avatarUrl || null,
avatarPrompt: null, // Will be set when avatar is generated
avatarPersonaId: null,
},
knobs: payload.knobs,
budgetCap: payload.budgetCap,
currentStep: 'analysis',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
}, []);
// Load project from database
const loadProjectFromDb = useCallback(async (projectId: string) => {
try {
const dbProject = await podcastApi.loadProject(projectId);
// Restore state from database
setState((prev) => ({
...prev,
project: {
id: dbProject.project_id,
idea: dbProject.idea,
duration: dbProject.duration,
speakers: dbProject.speakers,
avatarUrl: dbProject.avatar_url || null,
avatarPrompt: dbProject.avatar_prompt || null,
avatarPersonaId: dbProject.avatar_persona_id || null,
},
analysis: dbProject.analysis,
queries: dbProject.queries || [],
selectedQueries: new Set(dbProject.selected_queries || []),
research: dbProject.research,
rawResearch: dbProject.raw_research,
estimate: dbProject.estimate,
scriptData: dbProject.script_data,
renderJobs: dbProject.render_jobs || [],
knobs: dbProject.knobs || DEFAULT_KNOBS,
researchProvider: dbProject.research_provider || 'exa',
budgetCap: dbProject.budget_cap || 50,
showScriptEditor: dbProject.show_script_editor || false,
showRenderQueue: dbProject.show_render_queue || false,
currentStep: dbProject.current_step || null,
finalVideoUrl: dbProject.final_video_url || null,
createdAt: dbProject.created_at,
updatedAt: dbProject.updated_at,
}));
} catch (error) {
console.error('Error loading project from database:', error);
throw error;
}
}, []);
return {
// State
...state,
// Setters
setProject,
setAnalysis,
setQueries,
setSelectedQueries,
setResearch,
setRawResearch,
setEstimate,
setScriptData,
setRenderJobs,
updateRenderJob,
setKnobs,
setResearchProvider,
setBudgetCap,
setShowScriptEditor,
setShowRenderQueue,
setCurrentStep,
// Helpers
resetState,
initializeProject,
loadProjectFromDb,
};
};