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:
michael 2025-12-22 10:33:58 -06:00
parent 282f95dbc3
commit 58a4f1f627
13 changed files with 282 additions and 100 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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