From e371dc401ad8af19199ca650f4a3a29f85c59ee3 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 12 Jan 2026 09:05:56 -0600 Subject: [PATCH] feat: add save button to voice settings panel for TTS regeneration Add ability to save voice settings changes in QC Review screen without needing to approve the job. When saved, all TTS segments are regenerated across all languages with the new voice settings. Changes: - Add PUT /jobs/{id}/tts-preferences endpoint to update TTS preferences - Add UpdateTTSPreferencesRequest schema - Add updateTTSPreferences API method and useUpdateTTSPreferences hook - Add Save Voice Settings button with change detection to QCDetail Co-Authored-By: Claude Opus 4.5 --- backend/app/api/v1/routes_jobs.py | 150 +++++++++++++++++++++++++ backend/app/schemas/job.py | 5 + frontend/src/hooks/useJob.ts | 17 ++- frontend/src/lib/api.ts | 7 ++ frontend/src/routes/admin/QCDetail.tsx | 52 ++++++++- 5 files changed, 227 insertions(+), 4 deletions(-) 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. +

+ )} +
)}