From c1c0b876fcc98304258043c276f0d242f2cd9fe9 Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 1 Jan 2026 10:18:27 -0600 Subject: [PATCH] feat: add RENDER_FAILED status with error propagation to GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/models/job.py | 1 + backend/app/tasks/render_accessible_video.py | 113 ++++++++++++++----- frontend/src/components/StatusBadge.tsx | 3 + frontend/src/routes/jobs/JobDetail.tsx | 74 +++++++++++- frontend/src/routes/jobs/JobsList.tsx | 2 + frontend/src/types/api.ts | 1 + 6 files changed, 160 insertions(+), 34 deletions(-) diff --git a/backend/app/models/job.py b/backend/app/models/job.py index 3ac54ca..41b0515 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -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" diff --git a/backend/app/tasks/render_accessible_video.py b/backend/app/tasks/render_accessible_video.py index d035874..352a8d2 100644 --- a/backend/app/tasks/render_accessible_video.py +++ b/backend/app/tasks/render_accessible_video.py @@ -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( diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index 98a57f9..cb8fa03 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -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': diff --git a/frontend/src/routes/jobs/JobDetail.tsx b/frontend/src/routes/jobs/JobDetail.tsx index 242f14d..338afd4 100644 --- a/frontend/src/routes/jobs/JobDetail.tsx +++ b/frontend/src/routes/jobs/JobDetail.tsx @@ -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 ( +
+

Processing Progress

+
+ {steps.map((step, index) => ( +
+
+ + {step.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} + {index === failedAtIndex && ' (Failed)'} + +
+ ))} +
+
+

+ Processing failed at {failedStepLabel}. Check error details for more information. +

+
+
+ ); + } + return (

Processing Progress

@@ -46,7 +84,7 @@ const ProgressIndicator = ({ status }: { status: string }) => { index <= currentIndex ? 'bg-green-500' : 'bg-gray-300' }`} /> {step.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} @@ -397,8 +435,38 @@ export function JobDetail() { {/* Error Display */} {job.error && (
-

Processing Error

-

{String(job.error.message || 'Unknown error')}

+
+ + + +

Processing Error

+
+

+ {String(job.error.message || 'An error occurred during processing')} +

+ {job.error.type && ( +

+ Type: {String(job.error.type).replace(/_/g, ' ')} +

+ )} + {/* Show failed languages for render failures */} + {job.error.failed_languages && Array.isArray(job.error.failed_languages) && ( +
+

Failed Languages:

+
    + {(job.error.failed_languages as Array<{language: string; error: string}>).map((item, idx) => ( +
  • + {item.language.toUpperCase()}: {item.error} +
  • + ))} +
+
+ )} + {job.error.timestamp && ( +

+ {formatDistanceToNow(new Date(String(job.error.timestamp)))} ago +

+ )}
)}
diff --git a/frontend/src/routes/jobs/JobsList.tsx b/frontend/src/routes/jobs/JobsList.tsx index d27bb43..9c05955 100644 --- a/frontend/src/routes/jobs/JobsList.tsx +++ b/frontend/src/routes/jobs/JobsList.tsx @@ -24,7 +24,9 @@ const STATUS_LABELS: Record = { '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', }; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 8e15482..d08f528 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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";