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:
parent
b9c2fd93ac
commit
e371dc401a
5 changed files with 227 additions and 4 deletions
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue