diff --git a/frontend/src/components/VttEditor/VttEditor.tsx b/frontend/src/components/VttEditor/VttEditor.tsx index 18445bf..7ee6a53 100644 --- a/frontend/src/components/VttEditor/VttEditor.tsx +++ b/frontend/src/components/VttEditor/VttEditor.tsx @@ -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([]); const [errors, setErrors] = useState([]); const [editingCue, setEditingCue] = useState(null); @@ -310,6 +355,18 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCu )} + {/* CPS badge */} + {(() => { + const cps = computeCps(cue); + return cps > CPS_THRESHOLD ? ( +
+ + ⚡ {cps.toFixed(0)} CPS + +
+ ) : null; + })()} + {/* Text Content */} {editingCue === index ? (
- {cue.text} + {buildSegments(cue.text, glossaryTerms, language).map((seg, si) => + seg.glossaryMatch ? ( + + {seg.text} + + ) : ( + {seg.text} + ) + )}
{!readOnly && (