feat: add RENDER_FAILED status with error propagation to GUI

- Add RENDER_FAILED job status for when video rendering fails
- Fix _check_accessible_video_completion to detect failures and transition
  job status accordingly (was stuck in RENDERING_VIDEO forever)
- Store detailed error info in job.error including failed_languages array
- Call completion check after failures to properly update job status
- Broadcast WebSocket notification on render failures

Frontend:
- Add render_failed to JobStatus type and StatusBadge (red styling)
- Add tts_failed and render_failed to JobsList STATUS_LABELS
- Enhance JobDetail error display with:
  - Warning icon and prominent styling
  - Error type and message
  - Failed languages list with per-language errors
  - Timestamp of when error occurred
- Update ProgressIndicator to handle failed states with red dot

🤖 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 2026-01-01 10:18:27 -06:00
parent 0c1c115a8f
commit c1c0b876fc
6 changed files with 160 additions and 34 deletions

View file

@ -18,6 +18,7 @@ class JobStatus(str, Enum):
TTS_GENERATING = "tts_generating"
TTS_FAILED = "tts_failed" # TTS synthesis failed after retries, requires reprocessing
RENDERING_VIDEO = "rendering_video" # Accessible video rendering in progress
RENDER_FAILED = "render_failed" # Accessible video rendering failed, requires reprocessing
PENDING_FINAL_REVIEW = "pending_final_review"
COMPLETED = "completed"

View file

@ -293,6 +293,10 @@ async def _async_render_accessible_video(job_id: str, language: str):
}
)
# Check if all videos are now finished (completed or failed) to update job status
# This ensures the job transitions to RENDER_FAILED if all languages have finished
await _check_accessible_video_completion(job_id, db)
raise
finally:
@ -308,48 +312,95 @@ async def _check_accessible_video_completion(job_id: str, db):
progress = job_doc.get("accessible_video_progress", {})
requested_languages = job_doc["requested_outputs"]["languages"]
# Check if all requested languages have completed accessible video
all_complete = True
_any_failed = False
# Check status of all requested languages
all_finished = True # All languages have either completed or failed
any_failed = False
failed_languages = []
for language in requested_languages:
lang_progress = progress.get(language, {})
status = lang_progress.get("status", "pending")
if status == "failed":
_any_failed = True # noqa: F841 - reserved for future use
elif status != "completed":
all_complete = False
any_failed = True
failed_languages.append({
"language": language,
"error": lang_progress.get("error_message", "Unknown error")
})
elif status not in ["completed", "failed"]:
# Still pending or rendering
all_finished = False
if all_complete:
logger.info(f"All accessible videos complete for job {job_id}")
job_title = job_doc.get("title", "Untitled Job")
# If job is still in TTS_GENERATING or RENDERING_VIDEO, transition to PENDING_FINAL_REVIEW
if job_doc["status"] in [JobStatus.TTS_GENERATING.value, JobStatus.RENDERING_VIDEO.value]:
await db.jobs.update_one(
{"_id": job_id},
{
"$set": {
"status": JobStatus.PENDING_FINAL_REVIEW.value,
"updated_at": datetime.utcnow()
},
"$push": {
"review.history": {
"at": datetime.utcnow(),
"status": JobStatus.PENDING_FINAL_REVIEW.value,
"by": "system"
# Only update job status if all languages have finished processing
if all_finished:
if any_failed:
# Some or all videos failed - transition to RENDER_FAILED
logger.error(f"Accessible video rendering failed for job {job_id}: {len(failed_languages)} language(s) failed")
# Build error summary
error_summary = "; ".join([f"{f['language']}: {f['error']}" for f in failed_languages])
if job_doc["status"] in [JobStatus.TTS_GENERATING.value, JobStatus.RENDERING_VIDEO.value]:
await db.jobs.update_one(
{"_id": job_id},
{
"$set": {
"status": JobStatus.RENDER_FAILED.value,
"error": {
"type": "render_failure",
"failed_languages": failed_languages,
"message": f"Video rendering failed for {len(failed_languages)} language(s): {error_summary}",
"timestamp": datetime.utcnow().isoformat()
},
"updated_at": datetime.utcnow()
},
"$push": {
"review.history": {
"at": datetime.utcnow(),
"status": JobStatus.RENDER_FAILED.value,
"by": "system",
"notes": f"Rendering failed for: {', '.join([f['language'] for f in failed_languages])}"
}
}
}
}
)
)
job_title = job_doc.get("title", "Untitled Job")
broadcast_status_update(
job_id,
JobStatus.PENDING_FINAL_REVIEW.value,
job_title=job_title,
message=f"{job_title} has all accessible videos complete - ready for Final Review"
)
broadcast_status_update(
job_id,
JobStatus.RENDER_FAILED.value,
job_title=job_title,
message=f"{job_title} - Video rendering failed for {len(failed_languages)} language(s). Manual reprocessing required."
)
else:
# All videos completed successfully
logger.info(f"All accessible videos complete for job {job_id}")
if job_doc["status"] in [JobStatus.TTS_GENERATING.value, JobStatus.RENDERING_VIDEO.value]:
await db.jobs.update_one(
{"_id": job_id},
{
"$set": {
"status": JobStatus.PENDING_FINAL_REVIEW.value,
"updated_at": datetime.utcnow()
},
"$push": {
"review.history": {
"at": datetime.utcnow(),
"status": JobStatus.PENDING_FINAL_REVIEW.value,
"by": "system"
}
}
}
)
broadcast_status_update(
job_id,
JobStatus.PENDING_FINAL_REVIEW.value,
job_title=job_title,
message=f"{job_title} has all accessible videos complete - ready for Final Review"
)
async def _refine_pause_points_with_whisper(

View file

@ -20,6 +20,7 @@ export function StatusBadge({ status }: StatusBadgeProps) {
return 'bg-green-100 text-green-800';
case 'rejected':
case 'tts_failed':
case 'render_failed':
return 'bg-red-100 text-red-800';
case 'translating':
return 'bg-blue-100 text-blue-800';
@ -54,6 +55,8 @@ export function StatusBadge({ status }: StatusBadgeProps) {
return 'Rejected';
case 'tts_failed':
return 'TTS Failed';
case 'render_failed':
return 'Render Failed';
case 'translating':
return 'Translating';
case 'tts_generating':

View file

@ -16,6 +16,7 @@ const ProgressIndicator = ({ status }: { status: string }) => {
'approved', // Generic - matches both approved_english and approved_source
'translating',
'tts_generating',
'rendering_video',
'pending_final_review',
'completed'
];
@ -24,6 +25,12 @@ const ProgressIndicator = ({ status }: { status: string }) => {
const normalizedStatus = (status === 'approved_english' || status === 'approved_source') ? 'approved' : status;
const currentIndex = steps.indexOf(normalizedStatus);
const isRejected = status === 'rejected';
const isFailed = status === 'tts_failed' || status === 'render_failed';
// For failed states, find which step they failed at
const failedAtStep = status === 'tts_failed' ? 'tts_generating' :
status === 'render_failed' ? 'rendering_video' : null;
const failedAtIndex = failedAtStep ? steps.indexOf(failedAtStep) : -1;
if (isRejected) {
return (
@ -36,6 +43,37 @@ const ProgressIndicator = ({ status }: { status: string }) => {
);
}
if (isFailed) {
const failedStepLabel = failedAtStep?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Processing Progress</h3>
<div className="space-y-2">
{steps.map((step, index) => (
<div key={step} className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-3 ${
index < failedAtIndex ? 'bg-green-500' :
index === failedAtIndex ? 'bg-red-500' : 'bg-gray-300'
}`} />
<span className={`text-sm ${
index === failedAtIndex ? 'font-medium text-red-600' :
index < failedAtIndex ? 'text-gray-600' : 'text-gray-400'
}`}>
{step.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
{index === failedAtIndex && ' (Failed)'}
</span>
</div>
))}
</div>
<div className="mt-3 pt-3 border-t border-gray-200">
<p className="text-xs text-red-600">
Processing failed at {failedStepLabel}. Check error details for more information.
</p>
</div>
</div>
);
}
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Processing Progress</h3>
@ -46,7 +84,7 @@ const ProgressIndicator = ({ status }: { status: string }) => {
index <= currentIndex ? 'bg-green-500' : 'bg-gray-300'
}`} />
<span className={`text-sm ${
index === currentIndex ? 'font-medium text-gray-900' :
index === currentIndex ? 'font-medium text-gray-900' :
index < currentIndex ? 'text-gray-600' : 'text-gray-400'
}`}>
{step.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
@ -397,8 +435,38 @@ export function JobDetail() {
{/* Error Display */}
{job.error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-red-800 mb-2">Processing Error</h3>
<p className="text-xs text-red-600">{String(job.error.message || 'Unknown error')}</p>
<div className="flex items-start gap-2 mb-2">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h3 className="text-sm font-medium text-red-800">Processing Error</h3>
</div>
<p className="text-sm text-red-700 mb-2">
{String(job.error.message || 'An error occurred during processing')}
</p>
{job.error.type && (
<p className="text-xs text-red-600 mb-2">
<span className="font-medium">Type:</span> {String(job.error.type).replace(/_/g, ' ')}
</p>
)}
{/* Show failed languages for render failures */}
{job.error.failed_languages && Array.isArray(job.error.failed_languages) && (
<div className="mt-3 pt-3 border-t border-red-200">
<p className="text-xs font-medium text-red-800 mb-2">Failed Languages:</p>
<ul className="space-y-1">
{(job.error.failed_languages as Array<{language: string; error: string}>).map((item, idx) => (
<li key={idx} className="text-xs text-red-600">
<span className="font-medium">{item.language.toUpperCase()}:</span> {item.error}
</li>
))}
</ul>
</div>
)}
{job.error.timestamp && (
<p className="text-xs text-red-500 mt-2">
{formatDistanceToNow(new Date(String(job.error.timestamp)))} ago
</p>
)}
</div>
)}
</div>

View file

@ -24,7 +24,9 @@ const STATUS_LABELS: Record<string, string> = {
'qc_feedback': 'QC Feedback',
'translating': 'Translating',
'tts_generating': 'Generating Audio',
'tts_failed': 'TTS Failed',
'rendering_video': 'Rendering Video',
'render_failed': 'Render Failed',
'pending_final_review': 'Pending Final Review',
'completed': 'Completed',
};

View file

@ -11,6 +11,7 @@ export type JobStatus =
| "tts_generating"
| "tts_failed" // TTS synthesis failed after retries, requires reprocessing
| "rendering_video" // Accessible video rendering in progress
| "render_failed" // Accessible video rendering failed, requires reprocessing
| "pending_final_review"
| "completed";