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 <noreply@anthropic.com>
This commit is contained in:
michael 2026-01-12 09:05:56 -06:00
parent b9c2fd93ac
commit e371dc401a
5 changed files with 227 additions and 4 deletions

View file

@ -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()
)

View file

@ -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

View file

@ -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();

View file

@ -214,6 +214,13 @@ class ApiClient {
return response.data;
}
async updateTTSPreferences(id: string, tts_preferences: TTSPreferences): Promise<Job> {
const response = await this.client.put(`/jobs/${id}/tts-preferences`, {
tts_preferences
});
return response.data;
}
async getJobDownloads(id: string): Promise<JobDownloadsResponse> {
const response = await this.client.get(`/jobs/${id}/downloads`);
return response.data;

View file

@ -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<TTSPreferences | null>(null);
const [accessibleVideoMethod, setAccessibleVideoMethod] = useState<AccessibleVideoMethod>('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}
/>
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500">
Saving will regenerate all audio description audio with the new voice settings.
</p>
<button
type="button"
onClick={handleSaveVoiceSettings}
disabled={!voiceSettingsChanged || isProcessing}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{updateTTSPreferencesMutation.isPending ? 'Saving...' : 'Save Voice Settings'}
</button>
</div>
{voiceSettingsChanged && (
<p className="mt-2 text-xs text-amber-600">
Voice settings have been modified. Click "Save Voice Settings" to apply changes.
</p>
)}
</div>
</div>
)}
</div>