diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index acfa5da..c2cf2ab 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -28,6 +28,7 @@ from ...schemas.job import ( JobListResponse, JobResponse, RejectJobRequest, + UpdateTTSPreferencesRequest, VttContentResponse, VttTimingAdjustRequest, VttUpdateRequest, @@ -1872,3 +1873,152 @@ async def trigger_accessible_video_rerender( created_at=result["created_at"].isoformat(), updated_at=result["updated_at"].isoformat() ) + + +@router.put("/{job_id}/tts-preferences", response_model=JobResponse) +async def update_tts_preferences( + job_id: str, + request: UpdateTTSPreferencesRequest, + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """ + Update TTS preferences and regenerate all TTS segments for all languages. + + This endpoint: + 1. Updates the TTS preferences in the job + 2. Queues ALL cues for TTS regeneration for all languages with AD outputs + 3. Triggers re-render tasks for each language + """ + job_doc = await db.jobs.find_one({"_id": job_id}) + if not job_doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Job not found" + ) + + # Check job is in QC status + if job_doc["status"] not in [JobStatus.PENDING_QC.value]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Job must be in pending_qc status to update TTS preferences (current: {job_doc['status']})" + ) + + # Check if audio description MP3 is requested + if not job_doc.get("requested_outputs", {}).get("audio_description_mp3"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="TTS preferences can only be updated for jobs with audio description MP3 requested" + ) + + # Get languages with AD outputs + outputs = job_doc.get("outputs", {}) + languages_to_regenerate = [] + + for lang, lang_output in outputs.items(): + if lang_output.get("ad_vtt_gcs") and lang_output.get("ad_cues_gcs_prefix"): + languages_to_regenerate.append(lang) + + if not languages_to_regenerate: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No languages with audio description outputs found to regenerate" + ) + + # Update TTS preferences in the job + await db.jobs.update_one( + {"_id": job_id}, + { + "$set": { + "requested_outputs.tts_preferences": request.tts_preferences.model_dump(), + "status": JobStatus.TTS_GENERATING.value, + "updated_at": datetime.utcnow() + }, + "$push": { + "review.history": { + "at": datetime.utcnow(), + "status": JobStatus.TTS_GENERATING.value, + "by": str(current_user.id), + "notes": f"Updating TTS preferences and regenerating for {len(languages_to_regenerate)} language(s)" + } + } + } + ) + + # Import tts_synthesis for parsing AD cues + from ...tasks.tts_synthesis import parse_ad_cues + from ...tasks.rerender_accessible_video import rerender_accessible_video_task + + # For each language, get cue count and queue all cues, then trigger re-render + for lang in languages_to_regenerate: + lang_output = outputs[lang] + + # Download AD VTT to count cues + ad_vtt_gcs = lang_output.get("ad_vtt_gcs") + ad_blob_path = ad_vtt_gcs.replace(f"gs://{settings.gcs_bucket}/", "") + ad_blob = gcs_service.bucket.blob(ad_blob_path) + ad_vtt_content = ad_blob.download_as_text() + + # Parse cues to get count + cues = parse_ad_cues(ad_vtt_content) + all_cue_indices = list(range(len(cues))) + + logger.info( + f"Queuing {len(all_cue_indices)} cues for TTS regeneration in language {lang} for job {job_id}" + ) + + # Update TTS regeneration queue for this language + regeneration_queue = [ + {"cue_index": idx, "status": "pending", "queued_at": datetime.utcnow().isoformat()} + for idx in all_cue_indices + ] + + await db.jobs.update_one( + {"_id": job_id}, + { + "$set": { + f"outputs.{lang}.accessible_video_edit_state.tts_regeneration_queue": regeneration_queue, + "updated_at": datetime.utcnow() + } + } + ) + + # Trigger re-render task for this language + rerender_accessible_video_task.delay( + job_id=job_id, + language=lang, + regenerate_cue_indices=all_cue_indices, + whisper_refine=False + ) + + logger.info( + f"Triggered TTS regeneration re-render for job {job_id}/{lang} with {len(all_cue_indices)} cues" + ) + + # Broadcast status update + job_title = job_doc.get("title", "Untitled Job") + await connection_manager.broadcast_job_update( + job_id, + { + "type": "status_update", + "job_id": job_id, + "status": JobStatus.TTS_GENERATING.value, + "job_title": job_title, + "message": f"Regenerating TTS for {len(languages_to_regenerate)} language(s) with new voice settings" + } + ) + + # Get updated job + result = await db.jobs.find_one({"_id": job_id}) + + return JobResponse( + id=str(result["_id"]), + title=result["title"], + status=result["status"], + source=result["source"], + requested_outputs=RequestedOutputs(**result["requested_outputs"]), + review=result.get("review", {"notes": "", "history": []}), + outputs=result.get("outputs"), + created_at=result["created_at"].isoformat(), + updated_at=result["updated_at"].isoformat() + ) diff --git a/backend/app/schemas/job.py b/backend/app/schemas/job.py index 7c8dfaf..d71fb70 100644 --- a/backend/app/schemas/job.py +++ b/backend/app/schemas/job.py @@ -56,6 +56,11 @@ class ApproveSourceRequest(BaseModel): accessible_video_method: Optional[AccessibleVideoMethod] = None # User-selected method for accessible video +class UpdateTTSPreferencesRequest(BaseModel): + """Request to update TTS preferences and regenerate all TTS segments""" + tts_preferences: TTSPreferences + + class RejectJobRequest(BaseModel): notes: str diff --git a/frontend/src/hooks/useJob.ts b/frontend/src/hooks/useJob.ts index 3a9a985..2ca29fe 100644 --- a/frontend/src/hooks/useJob.ts +++ b/frontend/src/hooks/useJob.ts @@ -167,7 +167,7 @@ export function useUpdateJobVtt() { export function useRejectFinalReview() { const queryClient = useQueryClient(); - + return useMutation({ mutationFn: ({ id, notes }: { id: string; notes: string }) => apiClient.rejectFinalReview(id, notes), @@ -178,6 +178,21 @@ export function useRejectFinalReview() { }); } +export function useUpdateTTSPreferences() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, tts_preferences }: { id: string; tts_preferences: TTSPreferences }) => + apiClient.updateTTSPreferences(id, tts_preferences), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: ['jobs', id] }); + queryClient.invalidateQueries({ queryKey: ['jobs'] }); + // Also invalidate accessible video edit state as TTS is being regenerated + queryClient.invalidateQueries({ queryKey: ['jobs', id, 'accessible-video'] }); + }, + }); +} + export function useDeleteJob() { const queryClient = useQueryClient(); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cf01818..7e51649 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -214,6 +214,13 @@ class ApiClient { return response.data; } + async updateTTSPreferences(id: string, tts_preferences: TTSPreferences): Promise { + const response = await this.client.put(`/jobs/${id}/tts-preferences`, { + tts_preferences + }); + return response.data; + } + async getJobDownloads(id: string): Promise { const response = await this.client.get(`/jobs/${id}/downloads`); return response.data; diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index a3e88cc..f452511 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { useJob, useApproveEnglish, useRejectJob, useJobVttContent, useUpdateJobVtt, useJobDownloads, useAdjustVttTiming } from '../../hooks/useJob'; +import { useJob, useApproveEnglish, useRejectJob, useJobVttContent, useUpdateJobVtt, useJobDownloads, useAdjustVttTiming, useUpdateTTSPreferences } from '../../hooks/useJob'; import { useAccessibleVideoEditState, useUpdatePausePoint, @@ -60,6 +60,7 @@ export function QCDetail() { const rejectJobMutation = useRejectJob(); const updateVttMutation = useUpdateJobVtt(); const adjustTimingMutation = useAdjustVttTiming(); + const updateTTSPreferencesMutation = useUpdateTTSPreferences(); const [reviewNotes, setReviewNotes] = useState(''); const [showRejectForm, setShowRejectForm] = useState(false); @@ -80,9 +81,10 @@ export function QCDetail() { style_preset: 'neutral', custom_style_prompt: undefined }); + const [originalTtsPreferences, setOriginalTtsPreferences] = useState(null); const [accessibleVideoMethod, setAccessibleVideoMethod] = useState('pause_insert'); - const isProcessing = approveEnglishMutation.isPending || rejectJobMutation.isPending || updateVttMutation.isPending || adjustTimingMutation.isPending || rerenderMutation.isPending; + const isProcessing = approveEnglishMutation.isPending || rejectJobMutation.isPending || updateVttMutation.isPending || adjustTimingMutation.isPending || rerenderMutation.isPending || updateTTSPreferencesMutation.isPending; const isRendering = rerenderMutation.isPending || job?.status === 'rendering_qc'; // Initialize selected language from source language when job loads @@ -132,9 +134,16 @@ export function QCDetail() { useEffect(() => { if (job?.requested_outputs?.tts_preferences) { setTtsPreferences(job.requested_outputs.tts_preferences); + setOriginalTtsPreferences(job.requested_outputs.tts_preferences); } }, [job]); + // Check if voice settings have changed from the original + const voiceSettingsChanged = useMemo(() => { + if (!originalTtsPreferences) return false; + return JSON.stringify(ttsPreferences) !== JSON.stringify(originalTtsPreferences); + }, [ttsPreferences, originalTtsPreferences]); + // Keyboard shortcuts useEffect(() => { const handleKeyPress = (event: KeyboardEvent) => { @@ -258,6 +267,23 @@ export function QCDetail() { } }; + const handleSaveVoiceSettings = async () => { + if (!id) return; + + try { + await updateTTSPreferencesMutation.mutateAsync({ + id, + tts_preferences: ttsPreferences + }); + // Update original preferences to reflect the saved state + setOriginalTtsPreferences(ttsPreferences); + toast.toastOnly.success('Voice settings saved. TTS regeneration started for all languages.'); + } catch (error) { + console.error('Failed to save voice settings:', error); + toast.toastOnly.error('Failed to save voice settings. Please try again.'); + } + }; + const handleReject = async () => { if (!id || !reviewNotes.trim()) return; @@ -846,6 +872,26 @@ export function QCDetail() { onChange={setTtsPreferences} disabled={isProcessing} /> +
+
+

+ Saving will regenerate all audio description audio with the new voice settings. +

+ +
+ {voiceSettingsChanged && ( +

+ Voice settings have been modified. Click "Save Voice Settings" to apply changes. +

+ )} +
)}