feat: apply EN source VTT changes to all target languages

When a reviewer saves the source language VTT during QC and confirms
the re-translate dialog, all target languages are re-translated via
Celery. Job transitions to `translating` and returns to `pending_qc`
when done. Existing polling in useJob covers progress display.

- schemas/job.py: add `retranslate_languages: bool` to VttUpdateRequest
- audit_log.py: add VTT_RETRANSLATE audit action
- translate_and_synthesize_task: accept languages/retranslate params,
  filter to specified languages, skip video render, return to PENDING_QC
- routes_jobs.py: add _trigger_retranslation helper, call after VTT save
- types/api.ts: add retranslate_languages to VttUpdateRequest
- useJob.ts: invalidate all lang VTTs on retranslate
- QCDetail.tsx: confirmation dialog when saving source VTT with targets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-30 17:13:06 +01:00
parent ce4b3b0d95
commit 105895dd14
7 changed files with 151 additions and 14 deletions

View file

@ -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,

View file

@ -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"

View file

@ -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):

View file

@ -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

View file

@ -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'] });
}
},
});
}

View file

@ -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<HTMLInputElement>(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() {
</div>
)}
{/* Retranslate confirmation dialog */}
{showRetranslateDialog && pendingVttSave && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">Apply changes to all languages?</h3>
<p className="text-sm text-gray-600">
You edited the source VTT. Do you want to re-translate all target languages from the updated source?
Existing translations will be overwritten.
</p>
<div className="flex gap-3 justify-end pt-2">
<button
onClick={() => {
setShowRetranslateDialog(false);
_doSaveVtt(false, pendingVttSave.captions, pendingVttSave.ad);
setPendingVttSave(null);
}}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
No, source only
</button>
<button
onClick={() => {
setShowRetranslateDialog(false);
_doSaveVtt(true, pendingVttSave.captions, pendingVttSave.ad);
setPendingVttSave(null);
}}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
>
Yes, retranslate all
</button>
</div>
</div>
</div>
)}
{/* Share link modal */}
{showShareModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">

View file

@ -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 {