feat(l1-l6): glossary inline highlights + CPS warning in VttEditor
VttEditor:
- New props: glossaryTerms and language
- Glossary: source_term occurrences underlined (amber) with preferred translation
tooltip on hover; only terms that have a translation for the current language
- CPS badge: ⚡ N CPS shown in amber when characters-per-second > 20
QCDetail:
- Fetches active glossary for job's client (getGlossaries → find one with
current_version_id → getGlossaryTerms up to 500 terms)
- Passes glossaryTerms + language to both Captions and AD VttEditor instances
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
abf81515a4
commit
bb751033c0
2 changed files with 92 additions and 2 deletions
|
|
@ -1,5 +1,48 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { VTTParser, VTTValidator, type VTTCue } from '../../lib/vtt';
|
||||
import type { GlossaryTerm } from '../../types/api';
|
||||
|
||||
const CPS_THRESHOLD = 20;
|
||||
|
||||
function computeCps(cue: VTTCue): number {
|
||||
const chars = cue.text.replace(/[\n\r]/g, ' ').trim().length;
|
||||
const duration = cue.endTime - cue.startTime;
|
||||
if (duration <= 0) return 0;
|
||||
return chars / duration;
|
||||
}
|
||||
|
||||
type TextSegment = { text: string; glossaryMatch?: { preferred: string; notes?: string } };
|
||||
|
||||
function buildSegments(text: string, glossaryTerms: GlossaryTerm[], language: string): TextSegment[] {
|
||||
if (!glossaryTerms.length) return [{ text }];
|
||||
|
||||
// Build a combined regex from all source terms
|
||||
const patterns = glossaryTerms
|
||||
.filter(t => t.translations[language])
|
||||
.map(t => ({ pattern: t.source_term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), term: t }));
|
||||
if (!patterns.length) return [{ text }];
|
||||
|
||||
const regex = new RegExp(`(${patterns.map(p => p.pattern).join('|')})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
const segments: TextSegment[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
const matchedTerm = patterns.find(p => new RegExp(`^${p.pattern}$`, 'i').test(part));
|
||||
if (matchedTerm) {
|
||||
segments.push({
|
||||
text: part,
|
||||
glossaryMatch: {
|
||||
preferred: matchedTerm.term.translations[language] || '',
|
||||
notes: undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
segments.push({ text: part });
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Pure function for validating a single cue's timing (no hooks dependency)
|
||||
function validateCueTimingAtIndex(allCues: VTTCue[], index: number): string[] {
|
||||
|
|
@ -44,9 +87,11 @@ interface VttEditorProps {
|
|||
onCueDeleted?: (deletedIndex: number, totalCuesAfterDelete: number) => void;
|
||||
title: string;
|
||||
readOnly?: boolean;
|
||||
glossaryTerms?: GlossaryTerm[];
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, title, readOnly = false }: VttEditorProps) {
|
||||
export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, title, readOnly = false, glossaryTerms = [], language = 'en' }: VttEditorProps) {
|
||||
const [cues, setCues] = useState<VTTCue[]>([]);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [editingCue, setEditingCue] = useState<number | null>(null);
|
||||
|
|
@ -310,6 +355,18 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCu
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* CPS badge */}
|
||||
{(() => {
|
||||
const cps = computeCps(cue);
|
||||
return cps > CPS_THRESHOLD ? (
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<span className="text-xs px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded font-medium" title={`${cps.toFixed(1)} characters per second — above the ${CPS_THRESHOLD} CPS recommended limit`}>
|
||||
⚡ {cps.toFixed(0)} CPS
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* Text Content */}
|
||||
{editingCue === index ? (
|
||||
<CueEditor
|
||||
|
|
@ -320,7 +377,19 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCu
|
|||
) : (
|
||||
<div className="group">
|
||||
<div className="text-gray-900 whitespace-pre-wrap leading-relaxed">
|
||||
{cue.text}
|
||||
{buildSegments(cue.text, glossaryTerms, language).map((seg, si) =>
|
||||
seg.glossaryMatch ? (
|
||||
<span
|
||||
key={si}
|
||||
className="underline decoration-amber-400 decoration-2 underline-offset-2 cursor-help"
|
||||
title={`Preferred: ${seg.glossaryMatch.preferred}`}
|
||||
>
|
||||
{seg.text}
|
||||
</span>
|
||||
) : (
|
||||
<span key={si}>{seg.text}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -109,6 +109,23 @@ export function QCDetail() {
|
|||
});
|
||||
const langQcMap = langQcData?.language_qc ?? {};
|
||||
|
||||
// Glossary terms for inline highlighting
|
||||
const clientId = job?.client_id;
|
||||
const { data: glossariesData } = useQuery({
|
||||
queryKey: ['client-glossaries', clientId],
|
||||
queryFn: () => apiClient.getGlossaries(clientId!),
|
||||
enabled: !!clientId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const activeGlossaryId = glossariesData?.find(g => g.current_version_id)?.id;
|
||||
const { data: glossaryTermsData } = useQuery({
|
||||
queryKey: ['glossary-terms', clientId, activeGlossaryId],
|
||||
queryFn: () => apiClient.getGlossaryTerms(clientId!, activeGlossaryId!, { pageSize: 500 }),
|
||||
enabled: !!clientId && !!activeGlossaryId,
|
||||
staleTime: 120_000,
|
||||
});
|
||||
const glossaryTerms = glossaryTermsData?.terms ?? [];
|
||||
|
||||
const [showLangRejectModal, setShowLangRejectModal] = useState(false);
|
||||
const [langRejectNotes, setLangRejectNotes] = useState('');
|
||||
const [langRejectCategory, setLangRejectCategory] = useState('');
|
||||
|
|
@ -1551,6 +1568,8 @@ export function QCDetail() {
|
|||
onCueSave={handleCaptionsCueSave}
|
||||
title={`Closed Captions (${selectedLanguage.toUpperCase()})`}
|
||||
readOnly={isProcessing}
|
||||
glossaryTerms={glossaryTerms}
|
||||
language={selectedLanguage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1576,6 +1595,8 @@ export function QCDetail() {
|
|||
onCueDeleted={handleAdCueDeleted}
|
||||
title={`Audio Description (${selectedLanguage.toUpperCase()})`}
|
||||
readOnly={isProcessing}
|
||||
glossaryTerms={glossaryTerms}
|
||||
language={selectedLanguage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue