lyrics-transcriber 0.41.0__py3-none-any.whl → 0.43.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. lyrics_transcriber/core/controller.py +30 -52
  2. lyrics_transcriber/correction/anchor_sequence.py +325 -150
  3. lyrics_transcriber/correction/corrector.py +224 -107
  4. lyrics_transcriber/correction/handlers/base.py +28 -10
  5. lyrics_transcriber/correction/handlers/extend_anchor.py +47 -24
  6. lyrics_transcriber/correction/handlers/levenshtein.py +75 -33
  7. lyrics_transcriber/correction/handlers/llm.py +290 -0
  8. lyrics_transcriber/correction/handlers/no_space_punct_match.py +81 -36
  9. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +46 -26
  10. lyrics_transcriber/correction/handlers/repeat.py +28 -11
  11. lyrics_transcriber/correction/handlers/sound_alike.py +68 -32
  12. lyrics_transcriber/correction/handlers/syllables_match.py +80 -30
  13. lyrics_transcriber/correction/handlers/word_count_match.py +36 -19
  14. lyrics_transcriber/correction/handlers/word_operations.py +68 -22
  15. lyrics_transcriber/correction/text_utils.py +3 -7
  16. lyrics_transcriber/frontend/.yarn/install-state.gz +0 -0
  17. lyrics_transcriber/frontend/.yarn/releases/yarn-4.6.0.cjs +934 -0
  18. lyrics_transcriber/frontend/.yarnrc.yml +3 -0
  19. lyrics_transcriber/frontend/dist/assets/{index-DKnNJHRK.js → index-D0Gr3Ep7.js} +16509 -9038
  20. lyrics_transcriber/frontend/dist/assets/index-D0Gr3Ep7.js.map +1 -0
  21. lyrics_transcriber/frontend/dist/index.html +1 -1
  22. lyrics_transcriber/frontend/package.json +6 -2
  23. lyrics_transcriber/frontend/src/App.tsx +18 -2
  24. lyrics_transcriber/frontend/src/api.ts +103 -6
  25. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +14 -6
  26. lyrics_transcriber/frontend/src/components/DetailsModal.tsx +86 -59
  27. lyrics_transcriber/frontend/src/components/EditModal.tsx +281 -63
  28. lyrics_transcriber/frontend/src/components/FileUpload.tsx +2 -2
  29. lyrics_transcriber/frontend/src/components/Header.tsx +249 -0
  30. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +320 -266
  31. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +120 -0
  32. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +174 -52
  33. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +158 -114
  34. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +59 -78
  35. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +39 -16
  36. lyrics_transcriber/frontend/src/components/WordEditControls.tsx +4 -10
  37. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +134 -68
  38. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +1 -1
  39. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +85 -115
  40. lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
  41. lyrics_transcriber/frontend/src/components/shared/types.ts +15 -7
  42. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +67 -0
  43. lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
  44. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +7 -7
  45. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +121 -0
  46. lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
  47. lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
  48. lyrics_transcriber/frontend/src/types.js +2 -0
  49. lyrics_transcriber/frontend/src/types.ts +70 -49
  50. lyrics_transcriber/frontend/src/validation.ts +132 -0
  51. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  52. lyrics_transcriber/frontend/yarn.lock +3752 -0
  53. lyrics_transcriber/lyrics/base_lyrics_provider.py +75 -12
  54. lyrics_transcriber/lyrics/file_provider.py +6 -5
  55. lyrics_transcriber/lyrics/genius.py +5 -2
  56. lyrics_transcriber/lyrics/spotify.py +58 -21
  57. lyrics_transcriber/output/ass/config.py +16 -5
  58. lyrics_transcriber/output/cdg.py +1 -1
  59. lyrics_transcriber/output/generator.py +22 -8
  60. lyrics_transcriber/output/plain_text.py +15 -10
  61. lyrics_transcriber/output/segment_resizer.py +16 -3
  62. lyrics_transcriber/output/subtitles.py +27 -1
  63. lyrics_transcriber/output/video.py +107 -1
  64. lyrics_transcriber/review/__init__.py +0 -1
  65. lyrics_transcriber/review/server.py +337 -164
  66. lyrics_transcriber/transcribers/audioshake.py +3 -0
  67. lyrics_transcriber/transcribers/base_transcriber.py +11 -3
  68. lyrics_transcriber/transcribers/whisper.py +11 -1
  69. lyrics_transcriber/types.py +151 -105
  70. lyrics_transcriber/utils/word_utils.py +27 -0
  71. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/METADATA +3 -1
  72. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/RECORD +75 -61
  73. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/WHEEL +1 -1
  74. lyrics_transcriber/frontend/dist/assets/index-DKnNJHRK.js.map +0 -1
  75. lyrics_transcriber/frontend/package-lock.json +0 -4260
  76. lyrics_transcriber/frontend/src/components/shared/utils/initializeDataWithIds.tsx +0 -202
  77. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/LICENSE +0 -0
  78. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/entry_points.txt +0 -0
@@ -6,13 +6,12 @@ import {
6
6
  Button,
7
7
  Box,
8
8
  Typography,
9
- Paper,
10
- Collapse,
11
- IconButton,
9
+ Paper
12
10
  } from '@mui/material'
13
- import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
14
11
  import { CorrectionData } from '../types'
15
- import { useMemo, useState } from 'react'
12
+ import { useMemo, useRef, useEffect } from 'react'
13
+ import { ApiClient } from '../api'
14
+ import PreviewVideoSection from './PreviewVideoSection'
16
15
 
17
16
  interface ReviewChangesModalProps {
18
17
  open: boolean
@@ -20,6 +19,8 @@ interface ReviewChangesModalProps {
20
19
  originalData: CorrectionData
21
20
  updatedData: CorrectionData
22
21
  onSubmit: () => void
22
+ apiClient: ApiClient | null
23
+ setModalSpacebarHandler: (handler: (() => (e: KeyboardEvent) => void) | undefined) => void
23
24
  }
24
25
 
25
26
  interface DiffResult {
@@ -34,29 +35,29 @@ interface DiffResult {
34
35
  // Add interfaces for the word and segment structures
35
36
  interface Word {
36
37
  text: string
37
- start_time: number
38
- end_time: number
38
+ start_time: number | null
39
+ end_time: number | null
39
40
  id?: string
40
41
  }
41
42
 
42
43
  interface Segment {
43
44
  text: string
44
- start_time: number
45
- end_time: number
45
+ start_time: number | null
46
+ end_time: number | null
46
47
  words: Word[]
47
48
  id?: string
48
49
  }
49
50
 
50
51
  const normalizeWordForComparison = (word: Word): Omit<Word, 'id'> => ({
51
52
  text: word.text,
52
- start_time: word.start_time,
53
- end_time: word.end_time
53
+ start_time: word.start_time ?? 0, // Default to 0 for comparison
54
+ end_time: word.end_time ?? 0 // Default to 0 for comparison
54
55
  })
55
56
 
56
57
  const normalizeSegmentForComparison = (segment: Segment): Omit<Segment, 'id'> => ({
57
58
  text: segment.text,
58
- start_time: segment.start_time,
59
- end_time: segment.end_time,
59
+ start_time: segment.start_time ?? 0, // Default to 0 for comparison
60
+ end_time: segment.end_time ?? 0, // Default to 0 for comparison
60
61
  words: segment.words.map(normalizeWordForComparison)
61
62
  })
62
63
 
@@ -65,14 +66,40 @@ export default function ReviewChangesModal({
65
66
  onClose,
66
67
  originalData,
67
68
  updatedData,
68
- onSubmit
69
+ onSubmit,
70
+ apiClient,
71
+ setModalSpacebarHandler
69
72
  }: ReviewChangesModalProps) {
70
- const [expandedSegments, setExpandedSegments] = useState<number[]>([])
73
+ // Add ref to video element
74
+ const videoRef = useRef<HTMLVideoElement>(null)
75
+
76
+ // Add effect to handle spacebar
77
+ useEffect(() => {
78
+ if (open) {
79
+ setModalSpacebarHandler(() => (e: KeyboardEvent) => {
80
+ e.preventDefault()
81
+ e.stopPropagation()
82
+
83
+ if (videoRef.current) {
84
+ if (videoRef.current.paused) {
85
+ videoRef.current.play()
86
+ } else {
87
+ videoRef.current.pause()
88
+ }
89
+ }
90
+ })
91
+ } else {
92
+ setModalSpacebarHandler(undefined)
93
+ }
94
+
95
+ return () => {
96
+ setModalSpacebarHandler(undefined)
97
+ }
98
+ }, [open, setModalSpacebarHandler])
71
99
 
72
100
  const differences = useMemo(() => {
73
101
  const diffs: DiffResult[] = []
74
102
 
75
- // Compare corrected segments
76
103
  originalData.corrected_segments.forEach((originalSegment, index) => {
77
104
  const updatedSegment = updatedData.corrected_segments[index]
78
105
  if (!updatedSegment) {
@@ -89,26 +116,26 @@ export default function ReviewChangesModal({
89
116
  const normalizedUpdated = normalizeSegmentForComparison(updatedSegment)
90
117
  const wordChanges: DiffResult[] = []
91
118
 
92
- // Compare word-level changes based on position rather than IDs
93
- normalizedOriginal.words.forEach((word: Omit<Word, 'id'>, wordIndex: number) => {
119
+ // Compare word-level changes
120
+ normalizedOriginal.words.forEach((word, wordIndex) => {
94
121
  const updatedWord = normalizedUpdated.words[wordIndex]
95
122
  if (!updatedWord) {
96
123
  wordChanges.push({
97
124
  type: 'removed',
98
125
  path: `Word ${wordIndex}`,
99
- oldValue: `"${word.text}" (${word.start_time.toFixed(4)} - ${word.end_time.toFixed(4)})`
126
+ oldValue: `"${word.text}" (${word.start_time?.toFixed(4) ?? 'N/A'} - ${word.end_time?.toFixed(4) ?? 'N/A'})`
100
127
  })
101
128
  return
102
129
  }
103
130
 
104
131
  if (word.text !== updatedWord.text ||
105
- Math.abs(word.start_time - updatedWord.start_time) > 0.0001 ||
106
- Math.abs(word.end_time - updatedWord.end_time) > 0.0001) {
132
+ Math.abs((word.start_time ?? 0) - (updatedWord.start_time ?? 0)) > 0.0001 ||
133
+ Math.abs((word.end_time ?? 0) - (updatedWord.end_time ?? 0)) > 0.0001) {
107
134
  wordChanges.push({
108
135
  type: 'modified',
109
136
  path: `Word ${wordIndex}`,
110
- oldValue: `"${word.text}" (${word.start_time.toFixed(4)} - ${word.end_time.toFixed(4)})`,
111
- newValue: `"${updatedWord.text}" (${updatedWord.start_time.toFixed(4)} - ${updatedWord.end_time.toFixed(4)})`
137
+ oldValue: `"${word.text}" (${word.start_time?.toFixed(4) ?? 'N/A'} - ${word.end_time?.toFixed(4) ?? 'N/A'})`,
138
+ newValue: `"${updatedWord.text}" (${updatedWord.start_time?.toFixed(4) ?? 'N/A'} - ${updatedWord.end_time?.toFixed(4) ?? 'N/A'})`
112
139
  })
113
140
  }
114
141
  })
@@ -120,21 +147,22 @@ export default function ReviewChangesModal({
120
147
  wordChanges.push({
121
148
  type: 'added',
122
149
  path: `Word ${i}`,
123
- newValue: `"${word.text}" (${word.start_time.toFixed(4)} - ${word.end_time.toFixed(4)})`
150
+ newValue: `"${word.text}" (${word.start_time?.toFixed(4) ?? 'N/A'} - ${word.end_time?.toFixed(4) ?? 'N/A'})`
124
151
  })
125
152
  }
126
153
  }
127
154
 
155
+ // Compare segment-level changes
128
156
  if (normalizedOriginal.text !== normalizedUpdated.text ||
129
- Math.abs(normalizedOriginal.start_time - normalizedUpdated.start_time) > 0.0001 ||
130
- Math.abs(normalizedOriginal.end_time - normalizedUpdated.end_time) > 0.0001 ||
157
+ Math.abs((normalizedOriginal.start_time ?? 0) - (normalizedUpdated.start_time ?? 0)) > 0.0001 ||
158
+ Math.abs((normalizedOriginal.end_time ?? 0) - (normalizedUpdated.end_time ?? 0)) > 0.0001 ||
131
159
  wordChanges.length > 0) {
132
160
  diffs.push({
133
161
  type: 'modified',
134
162
  path: `Segment ${index}`,
135
163
  segmentIndex: index,
136
- oldValue: `"${normalizedOriginal.text}" (${normalizedOriginal.start_time.toFixed(4)} - ${normalizedOriginal.end_time.toFixed(4)})`,
137
- newValue: `"${normalizedUpdated.text}" (${normalizedUpdated.start_time.toFixed(4)} - ${normalizedUpdated.end_time.toFixed(4)})`,
164
+ oldValue: `"${normalizedOriginal.text}" (${normalizedOriginal.start_time?.toFixed(4) ?? 'N/A'} - ${normalizedOriginal.end_time?.toFixed(4) ?? 'N/A'})`,
165
+ newValue: `"${normalizedUpdated.text}" (${normalizedUpdated.start_time?.toFixed(4) ?? 'N/A'} - ${normalizedUpdated.end_time?.toFixed(4) ?? 'N/A'})`,
138
166
  wordChanges: wordChanges.length > 0 ? wordChanges : undefined
139
167
  })
140
168
  }
@@ -148,7 +176,7 @@ export default function ReviewChangesModal({
148
176
  type: 'added',
149
177
  path: `Segment ${i}`,
150
178
  segmentIndex: i,
151
- newValue: `"${segment.text}" (${segment.start_time.toFixed(4)} - ${segment.end_time.toFixed(4)})`
179
+ newValue: `"${segment.text}" (${segment.start_time?.toFixed(4) ?? 'N/A'} - ${segment.end_time?.toFixed(4) ?? 'N/A'})`
152
180
  })
153
181
  }
154
182
  }
@@ -156,82 +184,81 @@ export default function ReviewChangesModal({
156
184
  return diffs
157
185
  }, [originalData, updatedData])
158
186
 
159
- const handleToggleSegment = (segmentIndex: number) => {
160
- setExpandedSegments(prev =>
161
- prev.includes(segmentIndex)
162
- ? prev.filter(i => i !== segmentIndex)
163
- : [...prev, segmentIndex]
164
- )
165
- }
187
+ const renderCompactDiff = (diff: DiffResult) => {
188
+ if (diff.type !== 'modified') {
189
+ // For added/removed segments, show them as before but in a single line
190
+ return (
191
+ <Typography
192
+ key={diff.path}
193
+ color={diff.type === 'added' ? 'success.main' : 'error.main'}
194
+ sx={{ mb: 0.5 }}
195
+ >
196
+ {diff.segmentIndex}: {diff.type === 'added' ? '+ ' : '- '}
197
+ {diff.type === 'added' ? diff.newValue : diff.oldValue}
198
+ </Typography>
199
+ )
200
+ }
201
+
202
+ // For modified segments, create a unified inline diff view
203
+ const oldText = diff.oldValue?.split('"')[1] || ''
204
+ const newText = diff.newValue?.split('"')[1] || ''
205
+ const oldWords = oldText.split(' ')
206
+ const newWords = newText.split(' ')
207
+
208
+ // Extract timing info and format with 2 decimal places
209
+ const timingMatch = diff.newValue?.match(/\(([\d.]+) - ([\d.]+)\)/)
210
+ const timing = timingMatch ?
211
+ `(${parseFloat(timingMatch[1]).toFixed(2)} - ${parseFloat(timingMatch[2]).toFixed(2)})` :
212
+ ''
166
213
 
167
- const renderDiff = (diff: DiffResult) => {
168
- const getColor = () => {
169
- switch (diff.type) {
170
- case 'added': return 'success.main'
171
- case 'removed': return 'error.main'
172
- case 'modified': return 'warning.main'
173
- default: return 'text.primary'
214
+ // Create unified diff of words
215
+ const unifiedDiff = []
216
+ let i = 0, j = 0
217
+
218
+ while (i < oldWords.length || j < newWords.length) {
219
+ if (i < oldWords.length && j < newWords.length && oldWords[i] === newWords[j]) {
220
+ // Unchanged word
221
+ unifiedDiff.push({ type: 'unchanged', text: oldWords[i] })
222
+ i++
223
+ j++
224
+ } else if (i < oldWords.length && (!newWords[j] || oldWords[i] !== newWords[j])) {
225
+ // Deleted word
226
+ unifiedDiff.push({ type: 'deleted', text: oldWords[i] })
227
+ i++
228
+ } else if (j < newWords.length) {
229
+ // Added word
230
+ unifiedDiff.push({ type: 'added', text: newWords[j] })
231
+ j++
174
232
  }
175
233
  }
176
234
 
177
- const isExpanded = diff.segmentIndex !== undefined &&
178
- expandedSegments.includes(diff.segmentIndex)
179
-
180
235
  return (
181
- <Paper key={diff.path} sx={{ p: 2, mb: 1 }}>
182
- <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
183
- <Box>
184
- <Typography color={getColor()} sx={{ fontWeight: 'bold' }}>
185
- {diff.type.toUpperCase()}: {diff.path}
186
- </Typography>
187
- {diff.oldValue && (
188
- <Typography color="error.main" sx={{ ml: 2 }}>
189
- - {diff.oldValue}
190
- </Typography>
191
- )}
192
- {diff.newValue && (
193
- <Typography color="success.main" sx={{ ml: 2 }}>
194
- + {diff.newValue}
195
- </Typography>
196
- )}
197
- </Box>
198
- {diff.wordChanges && (
199
- <IconButton
200
- onClick={() => handleToggleSegment(diff.segmentIndex!)}
236
+ <Box key={diff.path} sx={{ mb: 0.5, display: 'flex', alignItems: 'center' }}>
237
+ <Typography variant="body2" color="text.secondary" sx={{ mr: 1, minWidth: '30px' }}>
238
+ {diff.segmentIndex}:
239
+ </Typography>
240
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', flexGrow: 1, alignItems: 'center' }}>
241
+ {unifiedDiff.map((word, idx) => (
242
+ <Typography
243
+ key={idx}
244
+ component="span"
245
+ color={
246
+ word.type === 'unchanged' ? 'text.primary' :
247
+ word.type === 'deleted' ? 'error.main' : 'success.main'
248
+ }
201
249
  sx={{
202
- transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
203
- transition: 'transform 0.2s'
250
+ textDecoration: word.type === 'deleted' ? 'line-through' : 'none',
251
+ mr: 0.5
204
252
  }}
205
253
  >
206
- <ExpandMoreIcon />
207
- </IconButton>
208
- )}
254
+ {word.text}
255
+ </Typography>
256
+ ))}
257
+ <Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
258
+ {timing}
259
+ </Typography>
209
260
  </Box>
210
-
211
- {diff.wordChanges && (
212
- <Collapse in={isExpanded}>
213
- <Box sx={{ mt: 2, ml: 4 }}>
214
- {diff.wordChanges.map((wordDiff, index) => (
215
- <Box key={index}>
216
- <Typography color={getColor()} variant="body2">
217
- {wordDiff.type.toUpperCase()}: {wordDiff.path}
218
- </Typography>
219
- {wordDiff.oldValue && (
220
- <Typography color="error.main" variant="body2" sx={{ ml: 2 }}>
221
- - {wordDiff.oldValue}
222
- </Typography>
223
- )}
224
- {wordDiff.newValue && (
225
- <Typography color="success.main" variant="body2" sx={{ ml: 2 }}>
226
- + {wordDiff.newValue}
227
- </Typography>
228
- )}
229
- </Box>
230
- ))}
231
- </Box>
232
- </Collapse>
233
- )}
234
- </Paper>
261
+ </Box>
235
262
  )
236
263
  }
237
264
 
@@ -243,24 +270,41 @@ export default function ReviewChangesModal({
243
270
  fullWidth
244
271
  >
245
272
  <DialogTitle>Review Changes</DialogTitle>
246
- <DialogContent dividers>
247
- {differences.length === 0 ? (
248
- <Box>
249
- <Typography color="text.secondary" sx={{ mb: 2 }}>
250
- No changes detected. You can still submit to continue processing.
251
- </Typography>
252
- <Typography variant="body2" color="text.secondary">
253
- Total segments: {updatedData.corrected_segments.length}
254
- </Typography>
255
- </Box>
256
- ) : (
257
- <Box>
258
- <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
259
- {differences.length} change{differences.length !== 1 ? 's' : ''} detected:
260
- </Typography>
261
- {differences.map(renderDiff)}
262
- </Box>
263
- )}
273
+ <DialogContent
274
+ dividers
275
+ sx={{
276
+ p: 0, // Remove default padding
277
+ '&:first-of-type': { pt: 0 } // Remove default top padding
278
+ }}
279
+ >
280
+ <PreviewVideoSection
281
+ apiClient={apiClient}
282
+ isModalOpen={open}
283
+ updatedData={updatedData}
284
+ videoRef={videoRef} // Pass the ref to PreviewVideoSection
285
+ />
286
+
287
+ <Box sx={{ p: 2, mt: 0 }}>
288
+ {differences.length === 0 ? (
289
+ <Box>
290
+ <Typography color="text.secondary">
291
+ No changes detected. You can still submit to continue processing.
292
+ </Typography>
293
+ <Typography variant="body2" color="text.secondary">
294
+ Total segments: {updatedData.corrected_segments.length}
295
+ </Typography>
296
+ </Box>
297
+ ) : (
298
+ <Box>
299
+ <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
300
+ {differences.length} segment{differences.length !== 1 ? 's' : ''} modified:
301
+ </Typography>
302
+ <Paper sx={{ p: 2 }}>
303
+ {differences.map(renderCompactDiff)}
304
+ </Paper>
305
+ </Box>
306
+ )}
307
+ </Box>
264
308
  </DialogContent>
265
309
  <DialogActions>
266
310
  <Button onClick={onClose}>Cancel</Button>
@@ -75,7 +75,6 @@ const TimelineWord = styled(Box)(({ theme }) => ({
75
75
 
76
76
  const ResizeHandle = styled(Box)(({ theme }) => ({
77
77
  position: 'absolute',
78
- right: -4,
79
78
  top: 0,
80
79
  width: 8,
81
80
  height: '100%',
@@ -83,6 +82,12 @@ const ResizeHandle = styled(Box)(({ theme }) => ({
83
82
  '&:hover': {
84
83
  backgroundColor: theme.palette.primary.light,
85
84
  },
85
+ '&.left': {
86
+ left: -4,
87
+ },
88
+ '&.right': {
89
+ right: -4,
90
+ }
86
91
  }))
87
92
 
88
93
  // Add new styled component for the cursor
@@ -101,7 +106,7 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
101
106
  const containerRef = useRef<HTMLDivElement>(null)
102
107
  const [dragState, setDragState] = useState<{
103
108
  wordIndex: number
104
- type: 'move' | 'resize'
109
+ type: 'move' | 'resize-left' | 'resize-right'
105
110
  initialX: number
106
111
  initialTime: number
107
112
  word: Word
@@ -116,40 +121,22 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
116
121
  isResize: boolean
117
122
  ): boolean => {
118
123
  if (isResize) {
119
- // If this is the last word, allow it to extend beyond the timeline
120
124
  if (currentIndex === words.length - 1) return false;
121
125
 
122
126
  const nextWord = words[currentIndex + 1]
123
- if (!nextWord) return false
124
- const hasCollision = proposedEnd > nextWord.start_time
125
- if (hasCollision) {
126
- console.log('Resize collision detected:', {
127
- proposedEnd,
128
- nextWordStart: nextWord.start_time,
129
- word: words[currentIndex].text,
130
- nextWord: nextWord.text
131
- })
132
- }
133
- return hasCollision
127
+ if (!nextWord || nextWord.start_time === null) return false
128
+ return proposedEnd > nextWord.start_time
134
129
  }
135
130
 
136
- // For move operations, check all words
137
131
  return words.some((word, index) => {
138
132
  if (index === currentIndex) return false
139
- const overlap = (
133
+ if (word.start_time === null || word.end_time === null) return false
134
+
135
+ return (
140
136
  (proposedStart >= word.start_time && proposedStart <= word.end_time) ||
141
137
  (proposedEnd >= word.start_time && proposedEnd <= word.end_time) ||
142
138
  (proposedStart <= word.start_time && proposedEnd >= word.end_time)
143
139
  )
144
- if (overlap) {
145
- console.log('Move collision detected:', {
146
- movingWord: words[currentIndex].text,
147
- collidingWord: word.text,
148
- proposedTimes: { start: proposedStart, end: proposedEnd },
149
- collidingTimes: { start: word.start_time, end: word.end_time }
150
- })
151
- }
152
- return overlap
153
140
  })
154
141
  }
155
142
 
@@ -188,27 +175,22 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
188
175
  return marks
189
176
  }
190
177
 
191
- const handleMouseDown = (e: React.MouseEvent, wordIndex: number, type: 'move' | 'resize') => {
178
+ const handleMouseDown = (e: React.MouseEvent, wordIndex: number, type: 'move' | 'resize-left' | 'resize-right') => {
192
179
  const rect = containerRef.current?.getBoundingClientRect()
193
180
  if (!rect) return
194
181
 
182
+ const word = words[wordIndex]
183
+ if (word.start_time === null || word.end_time === null) return
184
+
195
185
  const initialX = e.clientX - rect.left
196
186
  const initialTime = ((initialX / rect.width) * (endTime - startTime))
197
187
 
198
- console.log('Mouse down:', {
199
- type,
200
- wordIndex,
201
- initialX,
202
- initialTime,
203
- word: words[wordIndex]
204
- })
205
-
206
188
  setDragState({
207
189
  wordIndex,
208
190
  type,
209
191
  initialX,
210
192
  initialTime,
211
- word: words[wordIndex]
193
+ word
212
194
  })
213
195
  }
214
196
 
@@ -219,67 +201,58 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
219
201
  const x = e.clientX - rect.left
220
202
  const width = rect.width
221
203
 
222
- if (dragState.type === 'resize') {
223
- const currentWord = words[dragState.wordIndex]
224
- // Use the initial word duration for consistent scaling
204
+ const currentWord = words[dragState.wordIndex]
205
+ if (currentWord.start_time === null || currentWord.end_time === null ||
206
+ dragState.word.start_time === null || dragState.word.end_time === null) return
207
+
208
+ if (dragState.type === 'resize-right') {
225
209
  const initialWordDuration = dragState.word.end_time - dragState.word.start_time
226
210
  const initialWordWidth = (initialWordDuration / (endTime - startTime)) * width
227
-
228
- // Calculate how much the mouse has moved as a percentage of the initial word width
229
211
  const pixelDelta = x - dragState.initialX
230
212
  const percentageMoved = pixelDelta / initialWordWidth
231
213
  const timeDelta = initialWordDuration * percentageMoved
232
214
 
233
- console.log('Resize calculation:', {
234
- initialWordWidth,
235
- initialWordDuration,
236
- pixelDelta,
237
- percentageMoved,
238
- timeDelta,
239
- currentDuration: currentWord.end_time - currentWord.start_time
240
- })
241
-
242
215
  const proposedEnd = Math.max(
243
216
  currentWord.start_time + MIN_DURATION,
244
- dragState.word.end_time + timeDelta // Use initial end time as reference
217
+ dragState.word.end_time + timeDelta
245
218
  )
246
219
 
247
- // Check for collisions
248
220
  if (checkCollision(currentWord.start_time, proposedEnd, dragState.wordIndex, true)) return
249
221
 
250
- // If we get here, the resize is valid
251
222
  onWordUpdate(dragState.wordIndex, {
252
223
  start_time: currentWord.start_time,
253
224
  end_time: proposedEnd
254
225
  })
226
+ } else if (dragState.type === 'resize-left') {
227
+ const initialWordDuration = dragState.word.end_time - dragState.word.start_time
228
+ const initialWordWidth = (initialWordDuration / (endTime - startTime)) * width
229
+ const pixelDelta = x - dragState.initialX
230
+ const percentageMoved = pixelDelta / initialWordWidth
231
+ const timeDelta = initialWordDuration * percentageMoved
232
+
233
+ const proposedStart = Math.min(
234
+ currentWord.end_time - MIN_DURATION,
235
+ dragState.word.start_time + timeDelta
236
+ )
237
+
238
+ if (checkCollision(proposedStart, currentWord.end_time, dragState.wordIndex, true)) return
239
+
240
+ onWordUpdate(dragState.wordIndex, {
241
+ start_time: proposedStart,
242
+ end_time: currentWord.end_time
243
+ })
255
244
  } else if (dragState.type === 'move') {
256
- // Use timeline scale for consistent movement
257
245
  const pixelsPerSecond = width / (endTime - startTime)
258
246
  const pixelDelta = x - dragState.initialX
259
247
  const timeDelta = pixelDelta / pixelsPerSecond
260
248
 
261
- const currentWord = words[dragState.wordIndex]
262
249
  const wordDuration = currentWord.end_time - currentWord.start_time
263
-
264
- console.log('Move calculation:', {
265
- timelineWidth: width,
266
- timelineDuration: endTime - startTime,
267
- pixelsPerSecond,
268
- pixelDelta,
269
- timeDelta,
270
- currentDuration: wordDuration
271
- })
272
-
273
250
  const proposedStart = dragState.word.start_time + timeDelta
274
251
  const proposedEnd = proposedStart + wordDuration
275
252
 
276
- // Ensure we stay within timeline bounds
277
253
  if (proposedStart < startTime || proposedEnd > endTime) return
278
-
279
- // Check for collisions
280
254
  if (checkCollision(proposedStart, proposedEnd, dragState.wordIndex, false)) return
281
255
 
282
- // If we get here, the move is valid
283
256
  onWordUpdate(dragState.wordIndex, {
284
257
  start_time: proposedStart,
285
258
  end_time: proposedEnd
@@ -292,7 +265,7 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
292
265
  }
293
266
 
294
267
  const isWordHighlighted = (word: Word): boolean => {
295
- if (!currentTime || !word.start_time || !word.end_time) return false
268
+ if (!currentTime || word.start_time === null || word.end_time === null) return false
296
269
  return currentTime >= word.start_time && currentTime <= word.end_time
297
270
  }
298
271
 
@@ -332,12 +305,13 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
332
305
  />
333
306
 
334
307
  {words.map((word, index) => {
308
+ // Skip words with null timestamps
309
+ if (word.start_time === null || word.end_time === null) return null;
310
+
335
311
  const leftPosition = timeToPosition(word.start_time)
336
312
  const rightPosition = timeToPosition(word.end_time)
337
313
  const width = rightPosition - leftPosition
338
-
339
- // Add visual padding only to right side (2% of total width)
340
- const visualPadding = 2 // percentage points
314
+ const visualPadding = 2
341
315
  const adjustedWidth = Math.max(0, width - visualPadding)
342
316
 
343
317
  return (
@@ -345,21 +319,28 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
345
319
  key={index}
346
320
  className={isWordHighlighted(word) ? 'highlighted' : ''}
347
321
  sx={{
348
- left: `${leftPosition}%`, // No adjustment to left position
322
+ left: `${leftPosition}%`,
349
323
  width: `${adjustedWidth}%`,
350
- // Ensure the last word doesn't overflow
351
324
  maxWidth: `calc(${100 - leftPosition}% - 2px)`,
352
325
  }}
353
326
  onMouseDown={(e) => {
354
- e.stopPropagation(); // Prevent the parent's mousedown from firing
355
- handleMouseDown(e, index, 'move');
327
+ e.stopPropagation()
328
+ handleMouseDown(e, index, 'move')
356
329
  }}
357
330
  >
331
+ <ResizeHandle
332
+ className="left"
333
+ onMouseDown={(e) => {
334
+ e.stopPropagation()
335
+ handleMouseDown(e, index, 'resize-left')
336
+ }}
337
+ />
358
338
  {word.text}
359
339
  <ResizeHandle
340
+ className="right"
360
341
  onMouseDown={(e) => {
361
- e.stopPropagation(); // Prevent the parent's mousedown from firing
362
- handleMouseDown(e, index, 'resize');
342
+ e.stopPropagation()
343
+ handleMouseDown(e, index, 'resize-right')
363
344
  }}
364
345
  />
365
346
  </TimelineWord>