feat(pr4+pr5): hotkeys, unified status labels, upload size constant

PR-4 hotkeys (L-9):
- QCDetail: Cmd/Ctrl+S saves current VTT (handleSaveFullVtt)
- QCDetail: Escape closes both reject forms (final review + language reject modal)

PR-5 T-1 (unified status labels):
- Add JOB_STATUS_LABELS and getJobStatusLabel to utils/jobStatusMessages.ts
- JobsList.tsx: remove local STATUS_LABELS duplicate, import from shared util
- StatusBadge.tsx: remove 30-line switch duplicate, use getJobStatusLabel

PR-5 T-14 (unified upload size constant):
- config.py: upload_max_video_bytes = 2GB, upload_signed_url_ttl_hours = 24
- validation.py: use settings.upload_max_video_bytes instead of magic number
- notify.py: use settings.upload_signed_url_ttl_hours for signed URL TTL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-29 18:42:03 +01:00
parent 13db347d65
commit 1bf0fb9eed
7 changed files with 73 additions and 73 deletions

View file

@ -288,6 +288,10 @@ class Settings(BaseSettings):
cost_tracker_source_app: str = "video-accessibility"
cost_tracker_enabled: bool = True
# Upload limits (T-14 — single source of truth)
upload_max_video_bytes: int = 2 * 1024 * 1024 * 1024 # 2GB
upload_signed_url_ttl_hours: int = 24 # signed URL lifetime
# CORS - comma-separated list of allowed origins
cors_origins: str = "http://localhost:5173,http://localhost:5174,http://localhost:3000,http://localhost:6001"

View file

@ -3,6 +3,7 @@
import json
import re
import time
from ..core.config import settings
from typing import Any, Dict, List, Optional, Set
from fastapi import HTTPException, Request, status
from fastapi.responses import JSONResponse
@ -67,8 +68,8 @@ class RequestValidator:
self.compiled_patterns = [re.compile(pattern, re.IGNORECASE) for pattern in self.malicious_patterns]
# Max file sizes (in bytes)
self.max_video_size = 2 * 1024 * 1024 * 1024 # 2GB
# Max file sizes (in bytes) — sourced from central config (T-14)
self.max_video_size = settings.upload_max_video_bytes
self.max_subtitle_size = 10 * 1024 * 1024 # 10MB
# Request size limits

View file

@ -80,7 +80,7 @@ class NotifyClientTask(Task):
if "captions_vtt_gcs" in lang_output:
blob_path = lang_output["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
try:
signed_url = await get_signed_download_url(blob_path, 24)
signed_url = await get_signed_download_url(blob_path, settings.upload_signed_url_ttl_hours)
lang_downloads["captions_vtt"] = signed_url
except Exception as e:
logger.warning(f"Failed to generate signed URL for captions {language}: {e}")
@ -89,7 +89,7 @@ class NotifyClientTask(Task):
if "ad_vtt_gcs" in lang_output:
blob_path = lang_output["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
try:
signed_url = await get_signed_download_url(blob_path, 24)
signed_url = await get_signed_download_url(blob_path, settings.upload_signed_url_ttl_hours)
lang_downloads["audio_description_vtt"] = signed_url
except Exception as e:
logger.warning(f"Failed to generate signed URL for AD VTT {language}: {e}")
@ -98,7 +98,7 @@ class NotifyClientTask(Task):
if "ad_mp3_gcs" in lang_output:
blob_path = lang_output["ad_mp3_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
try:
signed_url = await get_signed_download_url(blob_path, 24)
signed_url = await get_signed_download_url(blob_path, settings.upload_signed_url_ttl_hours)
lang_downloads["audio_description_mp3"] = signed_url
except Exception as e:
logger.warning(f"Failed to generate signed URL for AD MP3 {language}: {e}")

View file

@ -1,4 +1,5 @@
import type { JobStatus } from '../types/api';
import { getJobStatusLabel } from '../utils/jobStatusMessages';
interface StatusBadgeProps {
status: JobStatus;
@ -37,40 +38,7 @@ export function StatusBadge({ status }: StatusBadgeProps) {
}
};
const getStatusLabel = (status: JobStatus) => {
switch (status) {
case 'created':
return 'Created';
case 'ingesting':
return 'Ingesting';
case 'ai_processing':
return 'AI Processing';
case 'pending_qc':
return 'Pending QC';
case 'approved_english':
return 'Approved (EN)';
case 'approved_source':
return 'Approved for Translation';
case 'rejected':
return 'Rejected';
case 'tts_failed':
return 'TTS Failed';
case 'render_failed':
return 'Render Failed';
case 'translating':
return 'Translating';
case 'tts_generating':
return 'Generating Audio';
case 'rendering_video':
return 'Rendering Video';
case 'pending_final_review':
return 'Pending Final Review';
case 'completed':
return 'Completed';
default:
return status;
}
};
const getStatusLabel = getJobStatusLabel;
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusStyles(status)}`}>

View file

@ -335,23 +335,27 @@ export function QCDetail() {
switch (event.key.toLowerCase()) {
case 'a':
if (event.ctrlKey || event.metaKey) return; // Don't interfere with Ctrl+A
if (event.ctrlKey || event.metaKey) return;
event.preventDefault();
if (!isProcessing) {
handleApprove();
}
if (!isProcessing) handleApprove();
break;
case 'r':
if (event.ctrlKey || event.metaKey) return; // Don't interfere with Ctrl+R
if (event.ctrlKey || event.metaKey) return;
event.preventDefault();
if (!isProcessing && !showRejectForm) {
setShowRejectForm(true);
if (!isProcessing && !showRejectForm) setShowRejectForm(true);
break;
case 's':
if (event.ctrlKey || event.metaKey) {
// Cmd/Ctrl+S — save current VTT (full file)
event.preventDefault();
if (!isProcessing && !updateVttMutation.isPending) {
handleSaveFullVtt();
}
}
break;
case 'escape':
if (showRejectForm) {
setShowRejectForm(false);
}
if (showRejectForm) setShowRejectForm(false);
if (showLangRejectModal) { setShowLangRejectModal(false); setLangRejectNotes(''); setLangRejectCategory(''); }
break;
case '1':
setViewMode('side-by-side');
@ -367,7 +371,7 @@ export function QCDetail() {
document.addEventListener('keydown', handleKeyPress);
return () => document.removeEventListener('keydown', handleKeyPress);
}, [isProcessing, showRejectForm]);
}, [isProcessing, showRejectForm, showLangRejectModal, updateVttMutation.isPending]);
const handleCaptionsChange = (content: string) => {
setCaptionsVtt(content);
@ -407,6 +411,24 @@ export function QCDetail() {
}
};
// Cmd+S full-VTT save (hotkey handler)
const handleSaveFullVtt = async () => {
if (!id || (!captionsVtt && !adVtt)) return;
try {
await updateVttMutation.mutateAsync({
id,
data: {
captions_vtt: captionsVtt || undefined,
audio_description_vtt: adVtt || undefined,
language: selectedLanguage,
}
});
toast.toastOnly.success('VTT saved');
} catch {
toast.toastOnly.error('Failed to save VTT');
}
};
// Immediate save handlers for individual cue edits
const handleCaptionsCueSave = async (cueIndex: number, vttContent: string) => {
if (!id) return;

View file

@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { formatDistanceToNow, subDays, isAfter, isPast, format } from 'date-fns';
import { useAuthStore } from '../../lib/auth';
import { JOB_STATUS_LABELS, getJobStatusLabel } from '../../utils/jobStatusMessages';
import { useJobs, useBulkDeleteJobs, useReprocessJob, useBulkReturnToQC, useCloneJob } from '../../hooks/useJob';
import { StatusBadge } from '../../components/StatusBadge';
import { useToastContext } from '../../contexts/ToastContext';
@ -18,26 +19,8 @@ const RETURN_TO_QC_ELIGIBLE_STATUSES = [
'tts_failed', 'render_failed', 'approved_english', 'approved_source',
];
// Human-readable status labels
const STATUS_LABELS: Record<string, string> = {
'created': 'Created',
'ingesting': 'Ingesting',
'ai_processing': 'AI Processing',
'pending_qc': 'Pending QC',
'approved_english': 'Approved (EN)',
'approved_source': 'Approved for Translation',
'rejected': 'Rejected',
'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',
};
const getStatusLabel = (status: string): string => STATUS_LABELS[status] || status;
// Status labels imported from shared util (T-1)
const getStatusLabel = getJobStatusLabel;
// Parse date string as UTC (backend returns UTC dates without timezone indicator)
const parseUTCDate = (dateString: string): Date => {
@ -154,7 +137,7 @@ export function JobsList() {
}, [jobsData?.jobs]);
// All possible statuses for the dropdown (static, server-side filtered)
const uniqueStatuses = Object.keys(STATUS_LABELS);
const uniqueStatuses = Object.keys(JOB_STATUS_LABELS);
// Handle column sort
const handleSort = (column: string) => {

View file

@ -126,7 +126,7 @@ export function getStatusMessageConfig(
*/
export function getProgressMessage(status: string, progress?: number): string {
const progressText = progress !== undefined ? ` (${progress}%)` : '';
switch (status) {
case 'ingesting':
return `Ingesting video${progressText}`;
@ -141,4 +141,26 @@ export function getProgressMessage(status: string, progress?: number): string {
default:
return status.replace(/_/g, ' ');
}
}
}
/** Single source of truth for human-readable job status labels. */
export const JOB_STATUS_LABELS: Record<string, string> = {
created: 'Created',
ingesting: 'Ingesting',
ai_processing: 'AI Processing',
pending_qc: 'Pending QC',
approved_english: 'Approved (EN)',
approved_source: 'Approved for Translation',
rejected: 'Rejected',
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',
};
export const getJobStatusLabel = (status: string): string =>
JOB_STATUS_LABELS[status] ?? status.replace(/_/g, ' ');