From 577ed44dabe7df2af9e11cf07ad9abb9369e258d Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 12 Jan 2026 14:24:36 -0600 Subject: [PATCH] fix: queue TTS regeneration for shifted cues when inserting AD cue When a new AD cue is inserted in the middle of existing cues, the system now automatically queues TTS regeneration for the new cue AND all cues that shifted positions. This ensures MP3 file indices stay synchronized with VTT cue indices, preventing cues from being silently dropped during re-render. Changes: - VttEditor: Add onCueInserted callback to notify parent of insertions - QCDetail: Track insertion context and queue TTS for all shifted cues - rerender_accessible_video: Add warning log when cue/MP3 count mismatch Co-Authored-By: Claude Opus 4.5 --- .../app/tasks/rerender_accessible_video.py | 11 +++++ .../src/components/VttEditor/VttEditor.tsx | 5 ++- frontend/src/routes/admin/QCDetail.tsx | 45 +++++++++++++++++-- 3 files changed, 56 insertions(+), 5 deletions(-) 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} />