add support for non-English original video uploads
- Upload form now has "English / Different language" radio with optional language hint
- Gemini auto-detects language and saves outputs to outputs.{detected_language}
- QC review dynamically loads/saves VTT for source language
- New APPROVED_SOURCE status for non-English videos (APPROVED_ENGLISH kept for backwards compat)
- Translation pipeline reads from source language and passes source_language to Google Translate
- All existing English jobs continue to work unchanged
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
282f95dbc3
commit
58a4f1f627
13 changed files with 282 additions and 100 deletions
|
|
@ -14,6 +14,7 @@ from ...models.job import JobStatus, RequestedOutputs
|
|||
from ...models.user import User, UserRole
|
||||
from ...schemas.job import (
|
||||
ApproveEnglishRequest,
|
||||
ApproveSourceRequest,
|
||||
AssetValidationResponse,
|
||||
BulkDeleteRequest,
|
||||
BulkDeleteResponse,
|
||||
|
|
@ -45,7 +46,8 @@ router = APIRouter(prefix="/jobs", tags=["jobs"])
|
|||
@router.post("", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_job(
|
||||
title: str = Form(...),
|
||||
language: str = Form("en"),
|
||||
source_is_english: bool = Form(True), # True = English source, False = other language (auto-detect)
|
||||
language_hint: Optional[str] = Form(None), # Optional hint when source_is_english=False
|
||||
requested_outputs: str = Form(...), # JSON string
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
|
@ -75,6 +77,11 @@ async def create_job(
|
|||
file, f"{job_id}/source.mp4"
|
||||
)
|
||||
|
||||
# Determine initial language setting
|
||||
# If English: set to "en"
|
||||
# If not English: set to "auto" (will be detected by AI) or use hint
|
||||
initial_language = "en" if source_is_english else (language_hint or "auto")
|
||||
|
||||
# Create job document
|
||||
job_data = {
|
||||
"_id": job_id,
|
||||
|
|
@ -84,7 +91,8 @@ async def create_job(
|
|||
"filename": f"{job_id}/source.mp4",
|
||||
"original_filename": file.filename,
|
||||
"gcs_uri": gcs_uri,
|
||||
"language": language
|
||||
"language": initial_language,
|
||||
"language_hint": language_hint if not source_is_english else None,
|
||||
},
|
||||
"requested_outputs": outputs.dict(),
|
||||
"status": JobStatus.CREATED.value,
|
||||
|
|
@ -335,18 +343,37 @@ async def get_job(
|
|||
)
|
||||
|
||||
|
||||
@router.post("/{job_id}/actions/approve_english", response_model=JobResponse)
|
||||
async def approve_english(
|
||||
@router.post("/{job_id}/actions/approve_source", response_model=JobResponse)
|
||||
async def approve_source(
|
||||
job_id: str,
|
||||
request: ApproveEnglishRequest,
|
||||
request: ApproveSourceRequest,
|
||||
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Approve the source language version (works for any language)"""
|
||||
# First, get the job to determine the source language
|
||||
job_doc = await db.jobs.find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Job not found"
|
||||
)
|
||||
|
||||
if job_doc["status"] != JobStatus.PENDING_QC.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Job not in pending QC status"
|
||||
)
|
||||
|
||||
# Determine the appropriate status based on source language
|
||||
source_language = job_doc["source"].get("language", "en")
|
||||
new_status = JobStatus.APPROVED_ENGLISH if source_language == "en" else JobStatus.APPROVED_SOURCE
|
||||
|
||||
result = await db.jobs.find_one_and_update(
|
||||
{"_id": job_id, "status": JobStatus.PENDING_QC.value},
|
||||
{
|
||||
"$set": {
|
||||
"status": JobStatus.APPROVED_ENGLISH.value,
|
||||
"status": new_status.value,
|
||||
"review.notes": request.notes or "",
|
||||
"review.reviewer_id": str(current_user.id),
|
||||
"updated_at": datetime.utcnow()
|
||||
|
|
@ -354,7 +381,7 @@ async def approve_english(
|
|||
"$push": {
|
||||
"review.history": {
|
||||
"at": datetime.utcnow(),
|
||||
"status": JobStatus.APPROVED_ENGLISH.value,
|
||||
"status": new_status.value,
|
||||
"by": str(current_user.id),
|
||||
"notes": request.notes or ""
|
||||
}
|
||||
|
|
@ -372,7 +399,7 @@ async def approve_english(
|
|||
# Trigger translation and synthesis pipeline immediately
|
||||
try:
|
||||
translate_and_synthesize_task.delay(job_id)
|
||||
logger.info(f"Triggered translation task for approved job {job_id}")
|
||||
logger.info(f"Triggered translation task for approved job {job_id} (source: {source_language})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to trigger translation task for job {job_id}: {e}")
|
||||
# Don't fail the approval, just log the error
|
||||
|
|
@ -390,6 +417,22 @@ async def approve_english(
|
|||
)
|
||||
|
||||
|
||||
@router.post("/{job_id}/actions/approve_english", response_model=JobResponse)
|
||||
async def approve_english(
|
||||
job_id: str,
|
||||
request: ApproveEnglishRequest,
|
||||
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Legacy endpoint - redirects to approve_source for backwards compatibility"""
|
||||
return await approve_source(
|
||||
job_id,
|
||||
ApproveSourceRequest(notes=request.notes),
|
||||
current_user,
|
||||
db
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{job_id}/actions/reject", response_model=JobResponse)
|
||||
async def reject_job(
|
||||
job_id: str,
|
||||
|
|
@ -656,11 +699,11 @@ async def get_job_downloads(
|
|||
@router.get("/{job_id}/vtt", response_model=VttContentResponse)
|
||||
async def get_job_vtt_content(
|
||||
job_id: str,
|
||||
language: str = "en",
|
||||
language: Optional[str] = None, # If None, defaults to source language
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Get VTT content for editing"""
|
||||
"""Get VTT content for editing. If language is not specified, returns source language content."""
|
||||
job_doc = await db.jobs.find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
raise HTTPException(
|
||||
|
|
@ -676,8 +719,11 @@ async def get_job_vtt_content(
|
|||
detail="Access denied"
|
||||
)
|
||||
|
||||
# Default to source language if not specified
|
||||
target_language = language or job_doc["source"].get("language", "en")
|
||||
|
||||
outputs = job_doc.get("outputs", {})
|
||||
lang_output = outputs.get(language, {})
|
||||
lang_output = outputs.get(target_language, {})
|
||||
|
||||
response = VttContentResponse()
|
||||
|
||||
|
|
@ -709,7 +755,7 @@ async def update_job_vtt_content(
|
|||
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Update VTT content for a job"""
|
||||
"""Update VTT content for a job. If language is not specified, updates source language content."""
|
||||
job_doc = await db.jobs.find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
raise HTTPException(
|
||||
|
|
@ -724,8 +770,11 @@ async def update_job_vtt_content(
|
|||
detail="VTT content can only be edited during QC phase"
|
||||
)
|
||||
|
||||
# Default to source language if not specified
|
||||
target_language = request.language or job_doc["source"].get("language", "en")
|
||||
|
||||
outputs = job_doc.get("outputs", {})
|
||||
lang_output = outputs.get(request.language, {})
|
||||
lang_output = outputs.get(target_language, {})
|
||||
|
||||
# Validate and update captions VTT
|
||||
if request.captions_vtt is not None:
|
||||
|
|
@ -740,7 +789,7 @@ async def update_job_vtt_content(
|
|||
# Upload updated VTT
|
||||
new_captions_uri = await upload_vtt_to_gcs(
|
||||
request.captions_vtt,
|
||||
f"{job_id}/{request.language}/captions.vtt"
|
||||
f"{job_id}/{target_language}/captions.vtt"
|
||||
)
|
||||
lang_output["captions_vtt_gcs"] = new_captions_uri
|
||||
|
||||
|
|
@ -757,12 +806,12 @@ async def update_job_vtt_content(
|
|||
# Upload updated VTT
|
||||
new_ad_uri = await upload_vtt_to_gcs(
|
||||
request.audio_description_vtt,
|
||||
f"{job_id}/{request.language}/ad.vtt"
|
||||
f"{job_id}/{target_language}/ad.vtt"
|
||||
)
|
||||
lang_output["ad_vtt_gcs"] = new_ad_uri
|
||||
|
||||
# Update job with new VTT content
|
||||
outputs[request.language] = lang_output
|
||||
outputs[target_language] = lang_output
|
||||
|
||||
result = await db.jobs.find_one_and_update(
|
||||
{"_id": job_id},
|
||||
|
|
@ -776,7 +825,7 @@ async def update_job_vtt_content(
|
|||
"at": datetime.utcnow(),
|
||||
"status": "vtt_updated",
|
||||
"by": str(current_user.id),
|
||||
"notes": f"Updated VTT content for {request.language}"
|
||||
"notes": f"Updated VTT content for {target_language}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ class JobStatus(str, Enum):
|
|||
INGESTING = "ingesting"
|
||||
AI_PROCESSING = "ai_processing"
|
||||
PENDING_QC = "pending_qc"
|
||||
APPROVED_ENGLISH = "approved_english"
|
||||
APPROVED_ENGLISH = "approved_english" # For English source videos
|
||||
APPROVED_SOURCE = "approved_source" # For non-English source videos
|
||||
REJECTED = "rejected"
|
||||
QC_FEEDBACK = "qc_feedback"
|
||||
TRANSLATING = "translating"
|
||||
|
|
@ -18,13 +19,20 @@ class JobStatus(str, Enum):
|
|||
PENDING_FINAL_REVIEW = "pending_final_review"
|
||||
COMPLETED = "completed"
|
||||
|
||||
@classmethod
|
||||
def is_approved(cls, status: str) -> bool:
|
||||
"""Check if status indicates source approval (any language)"""
|
||||
return status in [cls.APPROVED_ENGLISH.value, cls.APPROVED_SOURCE.value]
|
||||
|
||||
|
||||
class Source(BaseModel):
|
||||
filename: str
|
||||
original_filename: Optional[str] = None
|
||||
gcs_uri: str
|
||||
duration_s: Optional[float] = None
|
||||
language: constr(min_length=2, max_length=10) = "en"
|
||||
language: constr(min_length=2, max_length=10) = "en" # Final source language (from detection or explicit)
|
||||
language_hint: Optional[str] = None # User-provided hint for non-English videos
|
||||
detected_language: Optional[str] = None # AI-detected language from Gemini
|
||||
|
||||
|
||||
class RequestedOutputs(BaseModel):
|
||||
|
|
@ -82,7 +90,8 @@ class Job(BaseModel):
|
|||
|
||||
class JobCreate(BaseModel):
|
||||
title: str
|
||||
language: str = "en"
|
||||
source_is_english: bool = True # True = English source, False = other language (auto-detect)
|
||||
language_hint: Optional[str] = None # Optional hint when source_is_english=False
|
||||
requested_outputs: RequestedOutputs
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ class JobListResponse(BaseModel):
|
|||
|
||||
class JobCreateRequest(BaseModel):
|
||||
title: str
|
||||
language: str = "en"
|
||||
source_is_english: bool = True # True = English source, False = other language (auto-detect)
|
||||
language_hint: Optional[str] = None # Optional hint when source_is_english=False
|
||||
requested_outputs: RequestedOutputs
|
||||
|
||||
|
||||
|
|
@ -39,6 +40,11 @@ class ApproveEnglishRequest(BaseModel):
|
|||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ApproveSourceRequest(BaseModel):
|
||||
"""Request to approve source language content (works for any language)"""
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class RejectJobRequest(BaseModel):
|
||||
notes: str
|
||||
|
||||
|
|
@ -50,7 +56,7 @@ class CompleteJobRequest(BaseModel):
|
|||
class VttUpdateRequest(BaseModel):
|
||||
captions_vtt: Optional[str] = None
|
||||
audio_description_vtt: Optional[str] = None
|
||||
language: str = "en"
|
||||
language: Optional[str] = None # If None, defaults to source language
|
||||
|
||||
|
||||
class VttTimingAdjustRequest(BaseModel):
|
||||
|
|
|
|||
|
|
@ -14,9 +14,16 @@ class TranslateService:
|
|||
logger.warning("Google Translate API key not configured")
|
||||
self.client = None
|
||||
|
||||
async def translate_vtt(self, vtt_content: str, target_language: str) -> str:
|
||||
async def translate_vtt(
|
||||
self, vtt_content: str, target_language: str, source_language: str = "en"
|
||||
) -> str:
|
||||
"""
|
||||
Translate VTT content while preserving timing and structure
|
||||
Translate VTT content while preserving timing and structure.
|
||||
|
||||
Args:
|
||||
vtt_content: The VTT file content to translate
|
||||
target_language: The language code to translate to (e.g., 'es', 'fr')
|
||||
source_language: The source language code (default: 'en')
|
||||
"""
|
||||
if not self.client:
|
||||
raise ValueError("Google Translate not configured")
|
||||
|
|
@ -35,7 +42,7 @@ class TranslateService:
|
|||
results = self.client.translate(
|
||||
texts_to_translate,
|
||||
target_language=target_language,
|
||||
source_language="en"
|
||||
source_language=source_language # Use parameter instead of hardcoded "en"
|
||||
)
|
||||
|
||||
# Rebuild VTT with translated text
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ async def ingest_and_ai_task_impl(job_id: str):
|
|||
# Final safety check for required fields
|
||||
required_fields = ["captions_vtt", "audio_description_vtt"]
|
||||
missing_fields = [field for field in required_fields if field not in ai_result]
|
||||
|
||||
|
||||
if missing_fields:
|
||||
logger.error(f"Missing required fields after AI processing: {missing_fields}")
|
||||
# Create fallback content for missing fields
|
||||
|
|
@ -200,26 +200,49 @@ async def ingest_and_ai_task_impl(job_id: str):
|
|||
ai_result["audio_description_vtt"] = "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nVideo content with visual elements."
|
||||
logger.info("Created fallback audio_description_vtt")
|
||||
|
||||
# Upload VTT files to GCS
|
||||
# Get detected language from Gemini response
|
||||
detected_language = ai_result.get("language", "en")
|
||||
language_hint = job_doc["source"].get("language_hint")
|
||||
initial_language = job_doc["source"].get("language", "en")
|
||||
|
||||
# Log if there's a mismatch between hint/initial and detected language
|
||||
if language_hint and language_hint != detected_language:
|
||||
logger.warning(
|
||||
f"Language mismatch for job {job_id}: "
|
||||
f"hint={language_hint}, detected={detected_language}"
|
||||
)
|
||||
elif initial_language != "auto" and initial_language != detected_language:
|
||||
logger.info(
|
||||
f"Language detection for job {job_id}: "
|
||||
f"initial={initial_language}, detected={detected_language}"
|
||||
)
|
||||
|
||||
# Use detected language for output storage
|
||||
source_language = detected_language
|
||||
logger.info(f"Using detected language '{source_language}' for job {job_id}")
|
||||
|
||||
# Upload VTT files to GCS using detected language
|
||||
captions_gcs_uri = await upload_vtt_to_gcs(
|
||||
ai_result["captions_vtt"],
|
||||
f"{job_id}/en/captions.vtt"
|
||||
f"{job_id}/{source_language}/captions.vtt"
|
||||
)
|
||||
|
||||
ad_gcs_uri = await upload_vtt_to_gcs(
|
||||
ai_result["audio_description_vtt"],
|
||||
f"{job_id}/en/ad.vtt"
|
||||
f"{job_id}/{source_language}/ad.vtt"
|
||||
)
|
||||
|
||||
# Update job with AI results and outputs
|
||||
# Update job with AI results, detected language, and outputs
|
||||
await db.jobs.update_one(
|
||||
{"_id": job_id},
|
||||
{
|
||||
"$set": {
|
||||
"status": JobStatus.PENDING_QC.value,
|
||||
"source.language": source_language, # Update with detected language
|
||||
"source.detected_language": detected_language,
|
||||
"ai.ingestion_json": ai_result,
|
||||
"ai.confidence": ai_result["confidence"],
|
||||
"outputs.en": {
|
||||
f"outputs.{source_language}": {
|
||||
"captions_vtt_gcs": captions_gcs_uri,
|
||||
"ad_vtt_gcs": ad_gcs_uri
|
||||
},
|
||||
|
|
|
|||
|
|
@ -135,11 +135,14 @@ async def _async_translate_and_synthesize(job_id: str):
|
|||
job_title = job_doc.get("title", "Untitled Job")
|
||||
logger.info(f"✅ Found job document for {job_id} ({job_title}), status: {job_doc.get('status', 'UNKNOWN')}")
|
||||
|
||||
if job_doc["status"] != JobStatus.APPROVED_ENGLISH.value:
|
||||
logger.warning(f"⚠️ Job {job_id} not in approved_english status (current: {job_doc['status']}), skipping")
|
||||
# Check for any approved status (English or non-English source)
|
||||
if not JobStatus.is_approved(job_doc["status"]):
|
||||
logger.warning(f"⚠️ Job {job_id} not in approved status (current: {job_doc['status']}), skipping")
|
||||
return
|
||||
|
||||
logger.info(f"✅ Job {job_id} is in correct status, proceeding with translation")
|
||||
|
||||
# Get source language from job
|
||||
source_language = job_doc["source"].get("language", "en")
|
||||
logger.info(f"✅ Job {job_id} is in correct status, proceeding with translation (source: {source_language})")
|
||||
|
||||
# Update status to translating
|
||||
await db.jobs.update_one(
|
||||
|
|
@ -159,18 +162,20 @@ async def _async_translate_and_synthesize(job_id: str):
|
|||
}
|
||||
)
|
||||
|
||||
# Get English VTT content
|
||||
en_outputs = job_doc["outputs"]["en"]
|
||||
# Get source language VTT content
|
||||
source_outputs = job_doc["outputs"].get(source_language)
|
||||
if not source_outputs:
|
||||
raise ValueError(f"No outputs found for source language {source_language}")
|
||||
|
||||
# Download English VTT files
|
||||
captions_blob_path = en_outputs["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
|
||||
ad_blob_path = en_outputs["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
|
||||
# Download source language VTT files
|
||||
captions_blob_path = source_outputs["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
|
||||
ad_blob_path = source_outputs["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
|
||||
|
||||
captions_blob = gcs_service.bucket.blob(captions_blob_path)
|
||||
ad_blob = gcs_service.bucket.blob(ad_blob_path)
|
||||
|
||||
en_captions_vtt = captions_blob.download_as_text()
|
||||
en_ad_vtt = ad_blob.download_as_text()
|
||||
source_captions_vtt = captions_blob.download_as_text()
|
||||
source_ad_vtt = ad_blob.download_as_text()
|
||||
|
||||
# Process each requested language
|
||||
requested_languages = job_doc["requested_outputs"]["languages"]
|
||||
|
|
@ -179,22 +184,22 @@ async def _async_translate_and_synthesize(job_id: str):
|
|||
updated_outputs = job_doc.get("outputs", {})
|
||||
|
||||
for language in requested_languages:
|
||||
if language == "en":
|
||||
continue # Skip English as it's already processed
|
||||
if language == source_language:
|
||||
continue # Skip source language as it's already processed
|
||||
|
||||
logger.info(f"Processing language: {language}")
|
||||
logger.info(f"Processing language: {language} (from source: {source_language})")
|
||||
|
||||
try:
|
||||
if language in transcreation_languages:
|
||||
# Use transcreation for cultural adaptation with retry
|
||||
async def transcreate():
|
||||
return await gemini_service.transcreate_content(
|
||||
en_captions_vtt,
|
||||
en_ad_vtt,
|
||||
source_captions_vtt,
|
||||
source_ad_vtt,
|
||||
language,
|
||||
brief="Standard accessibility content"
|
||||
)
|
||||
|
||||
|
||||
result = await retry_with_backoff(transcreate, max_retries=3)
|
||||
translated_captions = result["captions_vtt"]
|
||||
translated_ad = result["audio_description_vtt"]
|
||||
|
|
@ -203,11 +208,15 @@ async def _async_translate_and_synthesize(job_id: str):
|
|||
else:
|
||||
# Use standard translation with retry
|
||||
async def translate_captions():
|
||||
return await translate_service.translate_vtt(en_captions_vtt, language)
|
||||
|
||||
return await translate_service.translate_vtt(
|
||||
source_captions_vtt, language, source_language=source_language
|
||||
)
|
||||
|
||||
async def translate_ad():
|
||||
return await translate_service.translate_vtt(en_ad_vtt, language)
|
||||
|
||||
return await translate_service.translate_vtt(
|
||||
source_ad_vtt, language, source_language=source_language
|
||||
)
|
||||
|
||||
translated_captions = await retry_with_backoff(translate_captions, max_retries=3)
|
||||
translated_ad = await retry_with_backoff(translate_ad, max_retries=3)
|
||||
origin = "translate"
|
||||
|
|
@ -260,7 +269,7 @@ async def _async_translate_and_synthesize(job_id: str):
|
|||
|
||||
# Generate TTS for languages that need MP3
|
||||
if job_doc["requested_outputs"]["audio_description_mp3"]:
|
||||
await _generate_tts_for_languages(job_id, updated_outputs, db)
|
||||
await _generate_tts_for_languages(job_id, updated_outputs, db, source_language)
|
||||
|
||||
# Update final status
|
||||
await db.jobs.update_one(
|
||||
|
|
@ -314,16 +323,16 @@ async def _async_translate_and_synthesize(job_id: str):
|
|||
client.close()
|
||||
|
||||
|
||||
async def _generate_tts_for_languages(job_id: str, outputs: dict[str, Any], db):
|
||||
async def _generate_tts_for_languages(job_id: str, outputs: dict[str, Any], db, source_language: str = "en"):
|
||||
"""Generate TTS audio for each language's audio description"""
|
||||
|
||||
# Always generate English MP3
|
||||
if "en" in outputs:
|
||||
await _generate_language_tts(job_id, "en", outputs["en"], db)
|
||||
# Always generate source language MP3 first
|
||||
if source_language in outputs and "ad_vtt_gcs" in outputs[source_language]:
|
||||
await _generate_language_tts(job_id, source_language, outputs[source_language], db)
|
||||
|
||||
# Generate for other languages
|
||||
for language, lang_output in outputs.items():
|
||||
if language != "en" and "ad_vtt_gcs" in lang_output:
|
||||
if language != source_language and "ad_vtt_gcs" in lang_output:
|
||||
await _generate_language_tts(job_id, language, lang_output, db)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export function StatusBadge({ status }: StatusBadgeProps) {
|
|||
case 'pending_qc':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'approved_english':
|
||||
case 'approved_source':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'rejected':
|
||||
return 'bg-red-100 text-red-800';
|
||||
|
|
@ -43,7 +44,9 @@ export function StatusBadge({ status }: StatusBadgeProps) {
|
|||
case 'pending_qc':
|
||||
return 'Pending QC';
|
||||
case 'approved_english':
|
||||
return 'Approved English';
|
||||
return 'Approved (EN)';
|
||||
case 'approved_source':
|
||||
return 'Approved for Translation';
|
||||
case 'rejected':
|
||||
return 'Rejected';
|
||||
case 'translating':
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ export function useJobDownloads(jobId: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export function useJobVttContent(jobId: string, language: string = 'en') {
|
||||
export function useJobVttContent(jobId: string, language?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['jobs', jobId, 'vtt', language],
|
||||
queryKey: ['jobs', jobId, 'vtt', language || 'source'],
|
||||
queryFn: () => apiClient.getJobVttContent(jobId, language),
|
||||
enabled: !!jobId,
|
||||
staleTime: 30000, // 30 seconds
|
||||
|
|
@ -86,7 +86,7 @@ export function useUpdateJob() {
|
|||
|
||||
export function useApproveEnglish() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
|
||||
apiClient.approveEnglish(id, notes),
|
||||
|
|
@ -97,6 +97,19 @@ export function useApproveEnglish() {
|
|||
});
|
||||
}
|
||||
|
||||
export function useApproveSource() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
|
||||
apiClient.approveSource(id, notes),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['jobs', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['jobs'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRejectJob() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -125,13 +138,14 @@ export function useCompleteJob() {
|
|||
|
||||
export function useUpdateJobVtt() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: VttUpdateRequest }) =>
|
||||
apiClient.updateJobVttContent(id, data),
|
||||
onSuccess: (_, { id, data }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['jobs', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['jobs', id, 'vtt', data.language] });
|
||||
// Invalidate the VTT cache for the specific language or 'source' if not specified
|
||||
queryClient.invalidateQueries({ queryKey: ['jobs', id, 'vtt', data.language || 'source'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,7 +141,10 @@ class ApiClient {
|
|||
async createJob(data: JobCreateRequest, file: File, onUploadProgress?: (progressEvent: { loaded: number; total: number }) => void): Promise<Job> {
|
||||
const formData = new FormData();
|
||||
formData.append('title', data.title);
|
||||
formData.append('language', data.language);
|
||||
formData.append('source_is_english', String(data.source_is_english));
|
||||
if (data.language_hint) {
|
||||
formData.append('language_hint', data.language_hint);
|
||||
}
|
||||
formData.append('requested_outputs', JSON.stringify(data.requested_outputs));
|
||||
formData.append('file', file);
|
||||
|
||||
|
|
@ -168,7 +171,12 @@ class ApiClient {
|
|||
}
|
||||
|
||||
async approveEnglish(id: string, notes?: string): Promise<Job> {
|
||||
const response = await this.client.post(`/jobs/${id}/actions/approve_english`, { notes });
|
||||
// Legacy method - calls approve_source for backwards compatibility
|
||||
return this.approveSource(id, notes);
|
||||
}
|
||||
|
||||
async approveSource(id: string, notes?: string): Promise<Job> {
|
||||
const response = await this.client.post(`/jobs/${id}/actions/approve_source`, { notes });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
@ -192,8 +200,9 @@ class ApiClient {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
async getJobVttContent(id: string, language: string = 'en'): Promise<VttContentResponse> {
|
||||
const response = await this.client.get(`/jobs/${id}/vtt?language=${language}`);
|
||||
async getJobVttContent(id: string, language?: string): Promise<VttContentResponse> {
|
||||
const params = language ? `?language=${language}` : '';
|
||||
const response = await this.client.get(`/jobs/${id}/vtt${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ export function QCDetail() {
|
|||
const navigate = useNavigate();
|
||||
const toast = useToastContext();
|
||||
const { data: job, isLoading, error } = useJob(id!);
|
||||
const { data: vttContent, isLoading: vttLoading } = useJobVttContent(id!, 'en');
|
||||
// Get source language from job (default to 'en' for backwards compatibility)
|
||||
const sourceLanguage = job?.source?.language || 'en';
|
||||
const { data: vttContent, isLoading: vttLoading } = useJobVttContent(id!, sourceLanguage);
|
||||
const { data: downloads } = useJobDownloads(id!);
|
||||
const approveEnglishMutation = useApproveEnglish();
|
||||
const rejectJobMutation = useRejectJob();
|
||||
|
|
@ -99,14 +101,14 @@ export function QCDetail() {
|
|||
|
||||
const saveVttChanges = async () => {
|
||||
if (!id || !hasUnsavedChanges) return;
|
||||
|
||||
|
||||
try {
|
||||
await updateVttMutation.mutateAsync({
|
||||
id,
|
||||
data: {
|
||||
captions_vtt: captionsVtt,
|
||||
audio_description_vtt: adVtt,
|
||||
language: 'en'
|
||||
language: sourceLanguage // Use source language instead of hardcoded 'en'
|
||||
}
|
||||
});
|
||||
setHasUnsavedChanges(false);
|
||||
|
|
@ -166,12 +168,12 @@ export function QCDetail() {
|
|||
|
||||
const handleTimingAdjustment = async () => {
|
||||
if (!id || timingOffset === 0) return;
|
||||
|
||||
|
||||
try {
|
||||
await adjustTimingMutation.mutateAsync({
|
||||
id,
|
||||
offsetSeconds: timingOffset,
|
||||
language: 'en',
|
||||
language: sourceLanguage, // Use source language instead of hardcoded 'en'
|
||||
adjustCaptions,
|
||||
adjustAudioDescription,
|
||||
});
|
||||
|
|
@ -218,6 +220,7 @@ export function QCDetail() {
|
|||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<StatusBadge status={job.status} />
|
||||
<span>Source: {job.source.filename}</span>
|
||||
<span>Language: {sourceLanguage.toUpperCase()}</span>
|
||||
{job.source.duration_s && (
|
||||
<span>Duration: {Math.round(job.source.duration_s)}s</span>
|
||||
)}
|
||||
|
|
@ -506,7 +509,7 @@ export function QCDetail() {
|
|||
disabled={isProcessing}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{approveEnglishMutation.isPending ? 'Approving...' : 'Approve English Version'}
|
||||
{approveEnglishMutation.isPending ? 'Approving...' : `Approve ${sourceLanguage.toUpperCase()} Version`}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -10,17 +10,19 @@ import { useGlobalWebSocket } from '../../contexts/GlobalWebSocketContext';
|
|||
const ProgressIndicator = ({ status }: { status: string }) => {
|
||||
const steps = [
|
||||
'created',
|
||||
'ingesting',
|
||||
'ingesting',
|
||||
'ai_processing',
|
||||
'pending_qc',
|
||||
'approved_english',
|
||||
'approved', // Generic - matches both approved_english and approved_source
|
||||
'translating',
|
||||
'tts_generating',
|
||||
'pending_final_review',
|
||||
'completed'
|
||||
];
|
||||
|
||||
const currentIndex = steps.indexOf(status);
|
||||
// Map approved statuses to generic 'approved' for progress display
|
||||
const normalizedStatus = (status === 'approved_english' || status === 'approved_source') ? 'approved' : status;
|
||||
const currentIndex = steps.indexOf(normalizedStatus);
|
||||
const isRejected = status === 'rejected';
|
||||
|
||||
if (isRejected) {
|
||||
|
|
@ -59,10 +61,12 @@ const ProgressIndicator = ({ status }: { status: string }) => {
|
|||
export function JobDetail() {
|
||||
const { id } = useParams();
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'video' | 'assets' | 'history'>('overview');
|
||||
|
||||
|
||||
const { data: job, isLoading, error } = useJob(id!);
|
||||
const { data: downloads } = useJobDownloads(id!);
|
||||
const { data: englishVtt } = useJobVttContent(id!, 'en');
|
||||
// Get source language from job (default to 'en' for backwards compatibility)
|
||||
const sourceLanguage = job?.source?.language || 'en';
|
||||
const { data: sourceVtt } = useJobVttContent(id!, sourceLanguage);
|
||||
|
||||
// Get connection status from global WebSocket
|
||||
const { connectionStatus } = useGlobalWebSocket();
|
||||
|
|
@ -184,8 +188,8 @@ export function JobDetail() {
|
|||
{videoUrl ? (
|
||||
<VideoWithCaptions
|
||||
videoUrl={videoUrl}
|
||||
captionsVtt={englishVtt?.captions_vtt || ''}
|
||||
audioDescriptionVtt={englishVtt?.audio_description_vtt || ''}
|
||||
captionsVtt={sourceVtt?.captions_vtt || ''}
|
||||
audioDescriptionVtt={sourceVtt?.audio_description_vtt || ''}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import type { JobCreateRequest } from '../../types/api';
|
|||
|
||||
const jobSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
language: z.string().min(2, 'Language is required'),
|
||||
sourceIsEnglish: z.boolean(),
|
||||
languageHint: z.string().optional(),
|
||||
captions_vtt: z.boolean(),
|
||||
audio_description_vtt: z.boolean(),
|
||||
audio_description_mp3: z.boolean(),
|
||||
|
|
@ -37,7 +38,8 @@ export function NewJob() {
|
|||
} = useForm<JobFormData>({
|
||||
resolver: zodResolver(jobSchema),
|
||||
defaultValues: {
|
||||
language: 'en',
|
||||
sourceIsEnglish: true,
|
||||
languageHint: '',
|
||||
captions_vtt: true,
|
||||
audio_description_vtt: true,
|
||||
audio_description_mp3: true,
|
||||
|
|
@ -48,6 +50,7 @@ export function NewJob() {
|
|||
|
||||
const languages = watch('languages');
|
||||
const transcreation = watch('transcreation');
|
||||
const sourceIsEnglish = watch('sourceIsEnglish');
|
||||
|
||||
const onSubmit = async (data: JobFormData) => {
|
||||
if (!selectedFile) {
|
||||
|
|
@ -57,7 +60,8 @@ export function NewJob() {
|
|||
|
||||
const jobData: JobCreateRequest = {
|
||||
title: data.title,
|
||||
language: data.language,
|
||||
source_is_english: data.sourceIsEnglish,
|
||||
language_hint: data.sourceIsEnglish ? undefined : data.languageHint || undefined,
|
||||
requested_outputs: {
|
||||
captions_vtt: data.captions_vtt,
|
||||
audio_description_vtt: data.audio_description_vtt,
|
||||
|
|
@ -232,22 +236,60 @@ export function NewJob() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Source Language */}
|
||||
{/* Original Video Language */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Source Language
|
||||
Original Video Language
|
||||
</label>
|
||||
<select
|
||||
{...register('language')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
</select>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
{...register('sourceIsEnglish', { setValueAs: (v) => v === 'true' || v === true })}
|
||||
value="true"
|
||||
className="mr-2"
|
||||
/>
|
||||
<span>English</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
{...register('sourceIsEnglish', { setValueAs: (v) => v === 'true' || v === true })}
|
||||
value="false"
|
||||
className="mr-2"
|
||||
/>
|
||||
<span>Different language (auto-detect)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language Hint - only shown when "Different language" is selected */}
|
||||
{!sourceIsEnglish && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Language Hint
|
||||
<span className="text-gray-500 font-normal ml-1">(optional - helps improve detection accuracy)</span>
|
||||
</label>
|
||||
<select
|
||||
{...register('languageHint')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Auto-detect (no hint)</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
<option value="pt">Portuguese</option>
|
||||
<option value="it">Italian</option>
|
||||
<option value="ja">Japanese</option>
|
||||
<option value="zh">Chinese</option>
|
||||
<option value="ko">Korean</option>
|
||||
<option value="ar">Arabic</option>
|
||||
<option value="ru">Russian</option>
|
||||
<option value="nl">Dutch</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requested Outputs */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
export type JobStatus =
|
||||
export type JobStatus =
|
||||
| "created"
|
||||
| "ingesting"
|
||||
| "ingesting"
|
||||
| "ai_processing"
|
||||
| "pending_qc"
|
||||
| "approved_english"
|
||||
| "approved_source" // For non-English source videos
|
||||
| "rejected"
|
||||
| "qc_feedback"
|
||||
| "translating"
|
||||
|
|
@ -31,7 +32,9 @@ export interface Source {
|
|||
video_gcs?: string;
|
||||
gcs_uri: string;
|
||||
duration_s?: number;
|
||||
language: string;
|
||||
language: string; // Final source language (from detection or explicit)
|
||||
language_hint?: string; // User-provided hint for non-English videos
|
||||
detected_language?: string; // AI-detected language from Gemini
|
||||
}
|
||||
|
||||
export interface RequestedOutputs {
|
||||
|
|
@ -120,7 +123,8 @@ export interface MicrosoftLoginResponse {
|
|||
|
||||
export interface JobCreateRequest {
|
||||
title: string;
|
||||
language: string;
|
||||
source_is_english: boolean; // True = English source, False = other language (auto-detect)
|
||||
language_hint?: string; // Optional hint when source_is_english=false
|
||||
requested_outputs: RequestedOutputs;
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +147,7 @@ export interface VttContentResponse {
|
|||
export interface VttUpdateRequest {
|
||||
captions_vtt?: string;
|
||||
audio_description_vtt?: string;
|
||||
language: string;
|
||||
language?: string; // If not specified, defaults to source language
|
||||
}
|
||||
|
||||
export interface AssetValidationResponse {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue