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:
parent
ce4b3b0d95
commit
105895dd14
7 changed files with 151 additions and 14 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue