From df721850e0d563d5702dbcc30a735124ca009e33 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 12 Jan 2026 14:29:25 -0600 Subject: [PATCH] fix: queue TTS regeneration for shifted cues when deleting AD cue When an AD cue is deleted, all subsequent cues shift positions but their MP3 files remain at the old indices. This adds handling to automatically queue TTS regeneration for all cues that shifted after a deletion. Changes: - VttEditor: Add onCueDeleted callback to notify parent of deletions - QCDetail: Track deletion context and queue TTS for all shifted cues Co-Authored-By: Claude Opus 4.5 --- .../src/components/VttEditor/VttEditor.tsx | 5 ++- frontend/src/routes/admin/QCDetail.tsx | 33 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/VttEditor/VttEditor.tsx b/frontend/src/components/VttEditor/VttEditor.tsx index 32f9d31..69b3cda 100644 --- a/frontend/src/components/VttEditor/VttEditor.tsx +++ b/frontend/src/components/VttEditor/VttEditor.tsx @@ -41,11 +41,12 @@ interface VttEditorProps { onChange: (content: string) => void; onCueSave?: (cueIndex: number, vttContent: string) => Promise; onCueInserted?: (insertedIndex: number, totalCues: number) => void; + onCueDeleted?: (deletedIndex: number, totalCuesAfterDelete: number) => void; title: string; readOnly?: boolean; } -export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, title, readOnly = false }: VttEditorProps) { +export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, title, readOnly = false }: VttEditorProps) { const [cues, setCues] = useState([]); const [errors, setErrors] = useState([]); const [editingCue, setEditingCue] = useState(null); @@ -170,6 +171,8 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, titl const updatedCues = cues.filter((_, i) => i !== index); const newVttContent = updateCuesLocal(updatedCues); setCueToDelete(null); + // Notify parent about deletion so it can queue TTS for shifted cues + onCueDeleted?.(index, updatedCues.length); // Save after delete - use index of deleted cue (will save full VTT) saveCue(index, newVttContent); }; diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index 2ade5f7..4e9d4b8 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -48,6 +48,12 @@ export function QCDetail() { totalCues: number; } | null>(null); + // Track AD cue deletions to queue TTS for shifted cues + const [lastDeletedAdCue, setLastDeletedAdCue] = useState<{ + deletedIndex: number; + totalCuesAfterDelete: number; + } | null>(null); + // Fetch VTT content for selected language const { data: vttContent, isLoading: vttLoading } = useJobVttContent(id!, selectedLanguage); @@ -104,6 +110,7 @@ export function QCDetail() { setPendingRegenerations([]); setPausePointsModified(false); setLastInsertedAdCue(null); + setLastDeletedAdCue(null); }, [selectedLanguage]); // Sync pending regenerations from server edit state @@ -235,6 +242,7 @@ export function QCDetail() { // Determine which cues need TTS regeneration let cueIndicesToRegenerate: number[]; + let actionDescription = 'saved'; if (lastInsertedAdCue && cueIndex === lastInsertedAdCue.insertIndex) { // This is the newly inserted cue being saved @@ -244,12 +252,27 @@ export function QCDetail() { (_, i) => lastInsertedAdCue.insertIndex + i ); setLastInsertedAdCue(null); // Clear insertion context + actionDescription = 'inserted'; + } else if (lastDeletedAdCue && cueIndex === lastDeletedAdCue.deletedIndex) { + // A cue was deleted - queue TTS for all cues that shifted (from deleted index to end) + // Note: cueIndex here points to the cue that now occupies the deleted position + cueIndicesToRegenerate = Array.from( + { length: lastDeletedAdCue.totalCuesAfterDelete - lastDeletedAdCue.deletedIndex }, + (_, i) => lastDeletedAdCue.deletedIndex + i + ); + setLastDeletedAdCue(null); // Clear deletion context + actionDescription = 'deleted'; } else { // Normal edit, just this cue cueIndicesToRegenerate = [cueIndex]; } - // Queue TTS regeneration for the cues + // Queue TTS regeneration for the cues (skip if no cues to regenerate, e.g., deleted last cue) + if (cueIndicesToRegenerate.length === 0) { + toast.toastOnly.success('AD cue deleted'); + return; + } + try { await queueTTSRegenerationMutation.mutateAsync({ jobId: id, @@ -262,7 +285,7 @@ export function QCDetail() { const cueCount = cueIndicesToRegenerate.length; if (cueCount > 1) { toast.toastOnly.success( - `AD cue inserted. Queued TTS for ${cueCount} cues (indices ${cueIndicesToRegenerate.join(', ')})` + `AD cue ${actionDescription}. Queued TTS for ${cueCount} cues (indices ${cueIndicesToRegenerate.join(', ')})` ); } else { toast.toastOnly.success(`AD cue ${cueIndex + 1} saved and queued for TTS`); @@ -278,6 +301,11 @@ export function QCDetail() { setLastInsertedAdCue({ insertIndex: insertedIndex, totalCues }); }; + // Handler for AD cue deletions - tracks context for TTS queueing + const handleAdCueDeleted = (deletedIndex: number, totalCuesAfterDelete: number) => { + setLastDeletedAdCue({ deletedIndex, totalCuesAfterDelete }); + }; + const handleApprove = async () => { if (!id) return; @@ -732,6 +760,7 @@ export function QCDetail() { onChange={handleAdChange} onCueSave={handleAdCueSave} onCueInserted={handleAdCueInserted} + onCueDeleted={handleAdCueDeleted} title={`Audio Description (${selectedLanguage.toUpperCase()})`} readOnly={isProcessing} />