diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index 03011af..8b0c289 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -1751,8 +1751,15 @@ async def update_job_vtt_content( "lang": target_language, "captions_updated": request.captions_vtt is not None, "ad_updated": request.audio_description_vtt is not None, + "retranslate": request.retranslate_languages, }, ) + + # Trigger retranslation of all target languages if requested + source_language = job_doc["source"].get("language", "en") + if request.retranslate_languages and target_language == source_language: + await _trigger_retranslation(job_id, job_doc, db, current_user) + return JobResponse( id=str(result["_id"]), title=result["title"], @@ -1766,6 +1773,48 @@ async def update_job_vtt_content( ) +async def _trigger_retranslation(job_id: str, job_doc: dict, db, current_user) -> None: + """Re-translate all target languages from the updated source VTT and return job to PENDING_QC.""" + source_language = job_doc["source"].get("language", "en") + target_languages = [ + lang for lang in job_doc.get("requested_outputs", {}).get("languages", []) + if lang != source_language + ] + if not target_languages: + return + + await db.jobs.update_one( + {"_id": job_id}, + { + "$set": { + "status": JobStatus.TRANSLATING.value, + "retranslation_triggered_at": datetime.utcnow(), + "updated_at": datetime.utcnow(), + }, + "$push": { + "review.history": { + "at": datetime.utcnow(), + "status": JobStatus.TRANSLATING.value, + "by": str(current_user.id), + "notes": "Re-translation triggered from updated source VTT" + } + } + } + ) + + await audit_logger.log_action( + action=AuditAction.VTT_RETRANSLATE, + description=f"Re-translation triggered for job {job_id} ({len(target_languages)} languages)", + user=current_user, + resource_type="job", + resource_id=job_id, + details={"target_languages": target_languages}, + ) + + from ...tasks.translate_and_synthesize import translate_and_synthesize_task + translate_and_synthesize_task.delay(job_id, languages=target_languages, retranslate=True) + + @router.post("/{job_id}/vtt/adjust-timing", response_model=JobResponse) async def adjust_vtt_timing( job_id: str, diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py index 9f6af99..8d7c150 100644 --- a/backend/app/models/audit_log.py +++ b/backend/app/models/audit_log.py @@ -51,6 +51,7 @@ class AuditAction(str, Enum): VTT_EDIT = "vtt.edit" VTT_APPROVE = "vtt.approve" VTT_REJECT = "vtt.reject" + VTT_RETRANSLATE = "vtt.retranslate" # Per-language QC actions LANGUAGE_QC_ASSIGN = "language_qc.assign" diff --git a/backend/app/schemas/job.py b/backend/app/schemas/job.py index 73a7699..5934ee9 100644 --- a/backend/app/schemas/job.py +++ b/backend/app/schemas/job.py @@ -76,6 +76,7 @@ class VttUpdateRequest(BaseModel): audio_description_vtt: str | None = None language: str | None = None # If None, defaults to source language if_match: str | None = None # Optimistic locking β€” SHA1 of expected current content + retranslate_languages: bool = False # Re-translate all target languages from updated source VTT class VttTimingAdjustRequest(BaseModel): diff --git a/backend/app/tasks/translate_and_synthesize.py b/backend/app/tasks/translate_and_synthesize.py index 02a1665..9450580 100644 --- a/backend/app/tasks/translate_and_synthesize.py +++ b/backend/app/tasks/translate_and_synthesize.py @@ -77,16 +77,18 @@ async def _mark_task_timed_out(job_id: str) -> None: @celery_app.task(bind=True, time_limit=3600, soft_time_limit=3400) -def translate_and_synthesize_task(self, job_id: str): +def translate_and_synthesize_task(self, job_id: str, languages: list[str] | None = None, retranslate: bool = False): """ Pipeline 2: Translation & MP3 Generation - Triggered when job status changes to 'approved_english' + Triggered when job status changes to 'approved_english'. + When called with languages/retranslate=True, re-translates only those languages + from the updated source VTT and returns job to PENDING_QC. """ - logger.info(f"πŸš€ CELERY TASK STARTED: translate_and_synthesize_task for job {job_id}") + logger.info(f"πŸš€ CELERY TASK STARTED: translate_and_synthesize_task for job {job_id} (retranslate={retranslate}, languages={languages})") try: logger.info(f"πŸ“ About to call asyncio.run for job {job_id}") - result = asyncio.run(_async_translate_and_synthesize(job_id)) + result = asyncio.run(_async_translate_and_synthesize(job_id, languages=languages, retranslate=retranslate)) logger.info(f"βœ… CELERY TASK COMPLETED successfully for job {job_id}") return result except SoftTimeLimitExceeded: @@ -103,7 +105,7 @@ def translate_and_synthesize_task(self, job_id: str): raise -async def _async_translate_and_synthesize(job_id: str): +async def _async_translate_and_synthesize(job_id: str, languages: list[str] | None = None, retranslate: bool = False): """Async implementation of translation and synthesis""" logger.info(f"πŸ”„ ASYNC FUNCTION STARTED: _async_translate_and_synthesize for job {job_id}") @@ -130,12 +132,13 @@ async def _async_translate_and_synthesize(job_id: str): } # Check for valid status to process translation - # Valid statuses: approved_english, approved_source (legacy), or translating (new workflow) + # Valid statuses: approved_english, approved_source (legacy), translating, or pending_qc (retranslation) current_status = job_doc["status"] valid_statuses = [ JobStatus.APPROVED_ENGLISH.value, JobStatus.APPROVED_SOURCE.value, JobStatus.TRANSLATING.value, + JobStatus.PENDING_QC.value, # allowed when retranslate=True ] if current_status not in valid_statuses: logger.warning(f"⚠️ Job {job_id} not in valid status for translation (current: {current_status}), skipping") @@ -177,8 +180,10 @@ async def _async_translate_and_synthesize(job_id: str): if not source_outputs: raise ValueError(f"No outputs found for source language {source_language}") - # Process each requested language + # Process each requested language (filtered to specific list when retranslating) requested_languages = job_doc["requested_outputs"]["languages"] + if languages is not None: + requested_languages = [l for l in requested_languages if l in languages] transcreation_languages = job_doc["requested_outputs"]["transcreation"] updated_outputs = job_doc.get("outputs", {}) @@ -492,6 +497,32 @@ async def _async_translate_and_synthesize(job_id: str): ) # Update final status + # When retranslating from QC, always return straight to PENDING_QC (skip video render) + if retranslate: + await db.jobs.update_one( + {"_id": job_id}, + { + "$set": { + "status": JobStatus.PENDING_QC.value, + "updated_at": datetime.utcnow() + }, + "$push": { + "review.history": { + "at": datetime.utcnow(), + "status": JobStatus.PENDING_QC.value, + "by": "system" + } + } + } + ) + broadcast_status_update( + job_id, + JobStatus.PENDING_QC.value, + job_title=job_title, + message=f"{job_title} re-translation complete β€” ready for QC Review" + ) + return + # NEW WORKFLOW: Translation pipeline now ends at PENDING_QC for QC review # If accessible video is requested, the render task will handle the transition # to PENDING_QC when all videos are complete diff --git a/frontend/src/hooks/useJob.ts b/frontend/src/hooks/useJob.ts index daa4336..e8174eb 100644 --- a/frontend/src/hooks/useJob.ts +++ b/frontend/src/hooks/useJob.ts @@ -173,8 +173,12 @@ export function useUpdateJobVtt() { apiClient.updateJobVttContent(id, data), onSuccess: (_, { id, data }) => { queryClient.invalidateQueries({ queryKey: ['jobs', id] }); - // Invalidate the VTT cache for the specific language or 'source' if not specified - queryClient.invalidateQueries({ queryKey: ['jobs', id, 'vtt', data.language || 'source'] }); + if (data.retranslate_languages) { + // Invalidate all language VTTs since retranslation will refresh them + queryClient.invalidateQueries({ queryKey: ['jobs', id, 'vtt'] }); + } else { + queryClient.invalidateQueries({ queryKey: ['jobs', id, 'vtt', data.language || 'source'] }); + } }, }); } diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index dbf855c..ea6ff69 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -306,6 +306,8 @@ export function QCDetail() { const [adjustAudioDescription, setAdjustAudioDescription] = useState(true); const [showVoiceSettings, setShowVoiceSettings] = useState(false); const [showDownloads, setShowDownloads] = useState(false); + const [showRetranslateDialog, setShowRetranslateDialog] = useState(false); + const [pendingVttSave, setPendingVttSave] = useState<{ captions?: string; ad?: string } | null>(null); const [adVttUploaded, setAdVttUploaded] = useState(false); const [renderJustCompleted, setRenderJustCompleted] = useState(false); const captionsFileInputRef = useRef(null); @@ -530,19 +532,20 @@ export function QCDetail() { }; // Cmd+S full-VTT save (hotkey handler) - const handleSaveFullVtt = async () => { - if (!id || (!captionsVtt && !adVtt)) return; + const _doSaveVtt = async (retranslate: boolean, captions?: string, ad?: string) => { + if (!id) return; try { await updateVttMutation.mutateAsync({ id, data: { - captions_vtt: captionsVtt || undefined, - audio_description_vtt: adVtt || undefined, + captions_vtt: captions || undefined, + audio_description_vtt: ad || undefined, language: selectedLanguage, if_match: vttEtag, + retranslate_languages: retranslate, } }); - toast.toastOnly.success('VTT saved'); + toast.toastOnly.success(retranslate ? 'VTT saved β€” re-translating all languages…' : 'VTT saved'); } catch (error) { const httpStatus = (error as { response?: { status?: number } })?.response?.status; if (httpStatus === 409) { @@ -553,6 +556,18 @@ export function QCDetail() { } }; + const handleSaveFullVtt = async () => { + if (!id || (!captionsVtt && !adVtt)) return; + const targetLanguages = job?.requested_outputs?.languages?.filter(l => l !== sourceLanguage) ?? []; + const savingSource = selectedLanguage === sourceLanguage; + if (savingSource && targetLanguages.length > 0) { + setPendingVttSave({ captions: captionsVtt || undefined, ad: adVtt || undefined }); + setShowRetranslateDialog(true); + return; + } + await _doSaveVtt(false, captionsVtt || undefined, adVtt || undefined); + }; + // Immediate save handlers for individual cue edits const handleCaptionsCueSave = async (cueIndex: number, vttContent: string) => { if (!id) return; @@ -1381,6 +1396,41 @@ export function QCDetail() { )} + {/* Retranslate confirmation dialog */} + {showRetranslateDialog && pendingVttSave && ( +
+
+

Apply changes to all languages?

+

+ You edited the source VTT. Do you want to re-translate all target languages from the updated source? + Existing translations will be overwritten. +

+
+ + +
+
+
+ )} + {/* Share link modal */} {showShareModal && (
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 8d42069..761996a 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -369,6 +369,7 @@ export interface VttUpdateRequest { audio_description_vtt?: string; language?: string; // If not specified, defaults to source language if_match?: string; // Optimistic locking β€” etag from last GET + retranslate_languages?: boolean; } export interface AssetValidationResponse {