diff --git a/backend/app/tasks/rerender_accessible_video.py b/backend/app/tasks/rerender_accessible_video.py index 268e0d4..bc03770 100644 --- a/backend/app/tasks/rerender_accessible_video.py +++ b/backend/app/tasks/rerender_accessible_video.py @@ -198,6 +198,17 @@ async def _async_rerender_accessible_video( logger.info(f"Downloaded {len(ad_segments)} AD cue segments") + # Validate VTT cue count matches MP3 count + vtt_cues = VTTParser.parse(ad_vtt_content) + downloaded_indices = set(idx for idx, _ in ad_segments) + if len(vtt_cues) != len(ad_segments): + missing_indices = set(range(len(vtt_cues))) - downloaded_indices + logger.warning( + f"VTT cue count ({len(vtt_cues)}) does not match MP3 count ({len(ad_segments)}). " + f"Missing MP3s for cue indices: {sorted(missing_indices)}. " + f"This may happen when a new AD cue is inserted but TTS wasn't regenerated for shifted cues." + ) + # 4. Build placements with adjusted pause points method = lang_output.get("accessible_video_method", "pause_insert") pause_points = edit_state.get("pause_points", []) diff --git a/frontend/src/components/VttEditor/VttEditor.tsx b/frontend/src/components/VttEditor/VttEditor.tsx index 7716fae..32f9d31 100644 --- a/frontend/src/components/VttEditor/VttEditor.tsx +++ b/frontend/src/components/VttEditor/VttEditor.tsx @@ -40,11 +40,12 @@ interface VttEditorProps { vttContent: string; onChange: (content: string) => void; onCueSave?: (cueIndex: number, vttContent: string) => Promise; + onCueInserted?: (insertedIndex: number, totalCues: number) => void; title: string; readOnly?: boolean; } -export function VttEditor({ vttContent, onChange, onCueSave, title, readOnly = false }: VttEditorProps) { +export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, title, readOnly = false }: VttEditorProps) { const [cues, setCues] = useState([]); const [errors, setErrors] = useState([]); const [editingCue, setEditingCue] = useState(null); @@ -159,6 +160,8 @@ export function VttEditor({ vttContent, onChange, onCueSave, title, readOnly = f updatedCues.splice(insertIndex, 0, newCue); updateCuesLocal(updatedCues); + // Notify parent about insertion so it can queue TTS for shifted cues + onCueInserted?.(insertIndex, updatedCues.length); // Auto-enter edit mode for the new cue setEditingCue(insertIndex); }; diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index cd06b9c..2ade5f7 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -42,6 +42,12 @@ export function QCDetail() { const [pendingRegenerations, setPendingRegenerations] = useState([]); const [pausePointsModified, setPausePointsModified] = useState(false); + // Track AD cue insertions to queue TTS for shifted cues + const [lastInsertedAdCue, setLastInsertedAdCue] = useState<{ + insertIndex: number; + totalCues: number; + } | null>(null); + // Fetch VTT content for selected language const { data: vttContent, isLoading: vttLoading } = useJobVttContent(id!, selectedLanguage); @@ -97,6 +103,7 @@ export function QCDetail() { useEffect(() => { setPendingRegenerations([]); setPausePointsModified(false); + setLastInsertedAdCue(null); }, [selectedLanguage]); // Sync pending regenerations from server edit state @@ -226,22 +233,51 @@ export function QCDetail() { } }); - // Queue TTS regeneration for this cue + // Determine which cues need TTS regeneration + let cueIndicesToRegenerate: number[]; + + if (lastInsertedAdCue && cueIndex === lastInsertedAdCue.insertIndex) { + // This is the newly inserted cue being saved + // Queue TTS for this cue AND all shifted cues (from insertIndex to end) + cueIndicesToRegenerate = Array.from( + { length: lastInsertedAdCue.totalCues - lastInsertedAdCue.insertIndex }, + (_, i) => lastInsertedAdCue.insertIndex + i + ); + setLastInsertedAdCue(null); // Clear insertion context + } else { + // Normal edit, just this cue + cueIndicesToRegenerate = [cueIndex]; + } + + // Queue TTS regeneration for the cues try { await queueTTSRegenerationMutation.mutateAsync({ jobId: id, language: selectedLanguage, - cueIndices: [cueIndex], + cueIndices: cueIndicesToRegenerate, }); // Update local pending regenerations - setPendingRegenerations(prev => [...new Set([...prev, cueIndex])]); - toast.toastOnly.success(`AD cue ${cueIndex + 1} saved and queued for TTS`); + setPendingRegenerations(prev => [...new Set([...prev, ...cueIndicesToRegenerate])]); + + const cueCount = cueIndicesToRegenerate.length; + if (cueCount > 1) { + toast.toastOnly.success( + `AD cue inserted. Queued TTS for ${cueCount} cues (indices ${cueIndicesToRegenerate.join(', ')})` + ); + } else { + toast.toastOnly.success(`AD cue ${cueIndex + 1} saved and queued for TTS`); + } } catch (queueError) { console.error('Failed to queue TTS regeneration:', queueError); toast.toastOnly.success(`AD cue ${cueIndex + 1} saved (TTS queue failed - queue manually)`); } }; + // Handler for AD cue insertions - tracks context for TTS queueing + const handleAdCueInserted = (insertedIndex: number, totalCues: number) => { + setLastInsertedAdCue({ insertIndex: insertedIndex, totalCues }); + }; + const handleApprove = async () => { if (!id) return; @@ -695,6 +731,7 @@ export function QCDetail() { vttContent={adVtt} onChange={handleAdChange} onCueSave={handleAdCueSave} + onCueInserted={handleAdCueInserted} title={`Audio Description (${selectedLanguage.toUpperCase()})`} readOnly={isProcessing} />