lyrics-transcriber 0.41.0__py3-none-any.whl → 0.42.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 (77) 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-coH8y7gV.js} +16284 -9032
  20. lyrics_transcriber/frontend/dist/assets/index-coH8y7gV.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 +7 -6
  26. lyrics_transcriber/frontend/src/components/DetailsModal.tsx +86 -59
  27. lyrics_transcriber/frontend/src/components/EditModal.tsx +93 -43
  28. lyrics_transcriber/frontend/src/components/FileUpload.tsx +2 -2
  29. lyrics_transcriber/frontend/src/components/Header.tsx +251 -0
  30. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +303 -265
  31. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +117 -0
  32. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +125 -40
  33. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +129 -115
  34. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +59 -78
  35. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +40 -16
  36. lyrics_transcriber/frontend/src/components/WordEditControls.tsx +4 -10
  37. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +137 -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 +35 -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.js +2 -0
  48. lyrics_transcriber/frontend/src/types.ts +70 -49
  49. lyrics_transcriber/frontend/src/validation.ts +132 -0
  50. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  51. lyrics_transcriber/frontend/yarn.lock +3752 -0
  52. lyrics_transcriber/lyrics/base_lyrics_provider.py +75 -12
  53. lyrics_transcriber/lyrics/file_provider.py +6 -5
  54. lyrics_transcriber/lyrics/genius.py +5 -2
  55. lyrics_transcriber/lyrics/spotify.py +58 -21
  56. lyrics_transcriber/output/ass/config.py +16 -5
  57. lyrics_transcriber/output/cdg.py +1 -1
  58. lyrics_transcriber/output/generator.py +22 -8
  59. lyrics_transcriber/output/plain_text.py +15 -10
  60. lyrics_transcriber/output/segment_resizer.py +16 -3
  61. lyrics_transcriber/output/subtitles.py +27 -1
  62. lyrics_transcriber/output/video.py +107 -1
  63. lyrics_transcriber/review/__init__.py +0 -1
  64. lyrics_transcriber/review/server.py +337 -164
  65. lyrics_transcriber/transcribers/audioshake.py +3 -0
  66. lyrics_transcriber/transcribers/base_transcriber.py +11 -3
  67. lyrics_transcriber/transcribers/whisper.py +11 -1
  68. lyrics_transcriber/types.py +151 -105
  69. lyrics_transcriber/utils/word_utils.py +27 -0
  70. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.42.0.dist-info}/METADATA +3 -1
  71. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.42.0.dist-info}/RECORD +74 -61
  72. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.42.0.dist-info}/WHEEL +1 -1
  73. lyrics_transcriber/frontend/dist/assets/index-DKnNJHRK.js.map +0 -1
  74. lyrics_transcriber/frontend/package-lock.json +0 -4260
  75. lyrics_transcriber/frontend/src/components/shared/utils/initializeDataWithIds.tsx +0 -202
  76. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.42.0.dist-info}/LICENSE +0 -0
  77. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.42.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 } 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,7 @@ interface ReviewChangesModalProps {
20
19
  originalData: CorrectionData
21
20
  updatedData: CorrectionData
22
21
  onSubmit: () => void
22
+ apiClient: ApiClient | null
23
23
  }
24
24
 
25
25
  interface DiffResult {
@@ -34,29 +34,29 @@ interface DiffResult {
34
34
  // Add interfaces for the word and segment structures
35
35
  interface Word {
36
36
  text: string
37
- start_time: number
38
- end_time: number
37
+ start_time: number | null
38
+ end_time: number | null
39
39
  id?: string
40
40
  }
41
41
 
42
42
  interface Segment {
43
43
  text: string
44
- start_time: number
45
- end_time: number
44
+ start_time: number | null
45
+ end_time: number | null
46
46
  words: Word[]
47
47
  id?: string
48
48
  }
49
49
 
50
50
  const normalizeWordForComparison = (word: Word): Omit<Word, 'id'> => ({
51
51
  text: word.text,
52
- start_time: word.start_time,
53
- end_time: word.end_time
52
+ start_time: word.start_time ?? 0, // Default to 0 for comparison
53
+ end_time: word.end_time ?? 0 // Default to 0 for comparison
54
54
  })
55
55
 
56
56
  const normalizeSegmentForComparison = (segment: Segment): Omit<Segment, 'id'> => ({
57
57
  text: segment.text,
58
- start_time: segment.start_time,
59
- end_time: segment.end_time,
58
+ start_time: segment.start_time ?? 0, // Default to 0 for comparison
59
+ end_time: segment.end_time ?? 0, // Default to 0 for comparison
60
60
  words: segment.words.map(normalizeWordForComparison)
61
61
  })
62
62
 
@@ -65,14 +65,12 @@ export default function ReviewChangesModal({
65
65
  onClose,
66
66
  originalData,
67
67
  updatedData,
68
- onSubmit
68
+ onSubmit,
69
+ apiClient
69
70
  }: ReviewChangesModalProps) {
70
- const [expandedSegments, setExpandedSegments] = useState<number[]>([])
71
-
72
71
  const differences = useMemo(() => {
73
72
  const diffs: DiffResult[] = []
74
73
 
75
- // Compare corrected segments
76
74
  originalData.corrected_segments.forEach((originalSegment, index) => {
77
75
  const updatedSegment = updatedData.corrected_segments[index]
78
76
  if (!updatedSegment) {
@@ -89,26 +87,26 @@ export default function ReviewChangesModal({
89
87
  const normalizedUpdated = normalizeSegmentForComparison(updatedSegment)
90
88
  const wordChanges: DiffResult[] = []
91
89
 
92
- // Compare word-level changes based on position rather than IDs
93
- normalizedOriginal.words.forEach((word: Omit<Word, 'id'>, wordIndex: number) => {
90
+ // Compare word-level changes
91
+ normalizedOriginal.words.forEach((word, wordIndex) => {
94
92
  const updatedWord = normalizedUpdated.words[wordIndex]
95
93
  if (!updatedWord) {
96
94
  wordChanges.push({
97
95
  type: 'removed',
98
96
  path: `Word ${wordIndex}`,
99
- oldValue: `"${word.text}" (${word.start_time.toFixed(4)} - ${word.end_time.toFixed(4)})`
97
+ oldValue: `"${word.text}" (${word.start_time?.toFixed(4) ?? 'N/A'} - ${word.end_time?.toFixed(4) ?? 'N/A'})`
100
98
  })
101
99
  return
102
100
  }
103
101
 
104
102
  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) {
103
+ Math.abs((word.start_time ?? 0) - (updatedWord.start_time ?? 0)) > 0.0001 ||
104
+ Math.abs((word.end_time ?? 0) - (updatedWord.end_time ?? 0)) > 0.0001) {
107
105
  wordChanges.push({
108
106
  type: 'modified',
109
107
  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)})`
108
+ oldValue: `"${word.text}" (${word.start_time?.toFixed(4) ?? 'N/A'} - ${word.end_time?.toFixed(4) ?? 'N/A'})`,
109
+ newValue: `"${updatedWord.text}" (${updatedWord.start_time?.toFixed(4) ?? 'N/A'} - ${updatedWord.end_time?.toFixed(4) ?? 'N/A'})`
112
110
  })
113
111
  }
114
112
  })
@@ -120,21 +118,22 @@ export default function ReviewChangesModal({
120
118
  wordChanges.push({
121
119
  type: 'added',
122
120
  path: `Word ${i}`,
123
- newValue: `"${word.text}" (${word.start_time.toFixed(4)} - ${word.end_time.toFixed(4)})`
121
+ newValue: `"${word.text}" (${word.start_time?.toFixed(4) ?? 'N/A'} - ${word.end_time?.toFixed(4) ?? 'N/A'})`
124
122
  })
125
123
  }
126
124
  }
127
125
 
126
+ // Compare segment-level changes
128
127
  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 ||
128
+ Math.abs((normalizedOriginal.start_time ?? 0) - (normalizedUpdated.start_time ?? 0)) > 0.0001 ||
129
+ Math.abs((normalizedOriginal.end_time ?? 0) - (normalizedUpdated.end_time ?? 0)) > 0.0001 ||
131
130
  wordChanges.length > 0) {
132
131
  diffs.push({
133
132
  type: 'modified',
134
133
  path: `Segment ${index}`,
135
134
  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)})`,
135
+ oldValue: `"${normalizedOriginal.text}" (${normalizedOriginal.start_time?.toFixed(4) ?? 'N/A'} - ${normalizedOriginal.end_time?.toFixed(4) ?? 'N/A'})`,
136
+ newValue: `"${normalizedUpdated.text}" (${normalizedUpdated.start_time?.toFixed(4) ?? 'N/A'} - ${normalizedUpdated.end_time?.toFixed(4) ?? 'N/A'})`,
138
137
  wordChanges: wordChanges.length > 0 ? wordChanges : undefined
139
138
  })
140
139
  }
@@ -148,7 +147,7 @@ export default function ReviewChangesModal({
148
147
  type: 'added',
149
148
  path: `Segment ${i}`,
150
149
  segmentIndex: i,
151
- newValue: `"${segment.text}" (${segment.start_time.toFixed(4)} - ${segment.end_time.toFixed(4)})`
150
+ newValue: `"${segment.text}" (${segment.start_time?.toFixed(4) ?? 'N/A'} - ${segment.end_time?.toFixed(4) ?? 'N/A'})`
152
151
  })
153
152
  }
154
153
  }
@@ -156,82 +155,81 @@ export default function ReviewChangesModal({
156
155
  return diffs
157
156
  }, [originalData, updatedData])
158
157
 
159
- const handleToggleSegment = (segmentIndex: number) => {
160
- setExpandedSegments(prev =>
161
- prev.includes(segmentIndex)
162
- ? prev.filter(i => i !== segmentIndex)
163
- : [...prev, segmentIndex]
164
- )
165
- }
158
+ const renderCompactDiff = (diff: DiffResult) => {
159
+ if (diff.type !== 'modified') {
160
+ // For added/removed segments, show them as before but in a single line
161
+ return (
162
+ <Typography
163
+ key={diff.path}
164
+ color={diff.type === 'added' ? 'success.main' : 'error.main'}
165
+ sx={{ mb: 0.5 }}
166
+ >
167
+ {diff.segmentIndex}: {diff.type === 'added' ? '+ ' : '- '}
168
+ {diff.type === 'added' ? diff.newValue : diff.oldValue}
169
+ </Typography>
170
+ )
171
+ }
172
+
173
+ // For modified segments, create a unified inline diff view
174
+ const oldText = diff.oldValue?.split('"')[1] || ''
175
+ const newText = diff.newValue?.split('"')[1] || ''
176
+ const oldWords = oldText.split(' ')
177
+ const newWords = newText.split(' ')
178
+
179
+ // Extract timing info and format with 2 decimal places
180
+ const timingMatch = diff.newValue?.match(/\(([\d.]+) - ([\d.]+)\)/)
181
+ const timing = timingMatch ?
182
+ `(${parseFloat(timingMatch[1]).toFixed(2)} - ${parseFloat(timingMatch[2]).toFixed(2)})` :
183
+ ''
166
184
 
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'
185
+ // Create unified diff of words
186
+ const unifiedDiff = []
187
+ let i = 0, j = 0
188
+
189
+ while (i < oldWords.length || j < newWords.length) {
190
+ if (i < oldWords.length && j < newWords.length && oldWords[i] === newWords[j]) {
191
+ // Unchanged word
192
+ unifiedDiff.push({ type: 'unchanged', text: oldWords[i] })
193
+ i++
194
+ j++
195
+ } else if (i < oldWords.length && (!newWords[j] || oldWords[i] !== newWords[j])) {
196
+ // Deleted word
197
+ unifiedDiff.push({ type: 'deleted', text: oldWords[i] })
198
+ i++
199
+ } else if (j < newWords.length) {
200
+ // Added word
201
+ unifiedDiff.push({ type: 'added', text: newWords[j] })
202
+ j++
174
203
  }
175
204
  }
176
205
 
177
- const isExpanded = diff.segmentIndex !== undefined &&
178
- expandedSegments.includes(diff.segmentIndex)
179
-
180
206
  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!)}
207
+ <Box key={diff.path} sx={{ mb: 0.5, display: 'flex', alignItems: 'center' }}>
208
+ <Typography variant="body2" color="text.secondary" sx={{ mr: 1, minWidth: '30px' }}>
209
+ {diff.segmentIndex}:
210
+ </Typography>
211
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', flexGrow: 1, alignItems: 'center' }}>
212
+ {unifiedDiff.map((word, idx) => (
213
+ <Typography
214
+ key={idx}
215
+ component="span"
216
+ color={
217
+ word.type === 'unchanged' ? 'text.primary' :
218
+ word.type === 'deleted' ? 'error.main' : 'success.main'
219
+ }
201
220
  sx={{
202
- transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
203
- transition: 'transform 0.2s'
221
+ textDecoration: word.type === 'deleted' ? 'line-through' : 'none',
222
+ mr: 0.5
204
223
  }}
205
224
  >
206
- <ExpandMoreIcon />
207
- </IconButton>
208
- )}
225
+ {word.text}
226
+ </Typography>
227
+ ))}
228
+ <Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
229
+ {timing}
230
+ </Typography>
209
231
  </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>
232
+ </Box>
235
233
  )
236
234
  }
237
235
 
@@ -243,24 +241,40 @@ export default function ReviewChangesModal({
243
241
  fullWidth
244
242
  >
245
243
  <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
- )}
244
+ <DialogContent
245
+ dividers
246
+ sx={{
247
+ p: 0, // Remove default padding
248
+ '&:first-of-type': { pt: 0 } // Remove default top padding
249
+ }}
250
+ >
251
+ <PreviewVideoSection
252
+ apiClient={apiClient}
253
+ isModalOpen={open}
254
+ updatedData={updatedData}
255
+ />
256
+
257
+ <Box sx={{ p: 2, mt: 0 }}>
258
+ {differences.length === 0 ? (
259
+ <Box>
260
+ <Typography color="text.secondary">
261
+ No changes detected. You can still submit to continue processing.
262
+ </Typography>
263
+ <Typography variant="body2" color="text.secondary">
264
+ Total segments: {updatedData.corrected_segments.length}
265
+ </Typography>
266
+ </Box>
267
+ ) : (
268
+ <Box>
269
+ <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
270
+ {differences.length} segment{differences.length !== 1 ? 's' : ''} modified:
271
+ </Typography>
272
+ <Paper sx={{ p: 2 }}>
273
+ {differences.map(renderCompactDiff)}
274
+ </Paper>
275
+ </Box>
276
+ )}
277
+ </Box>
264
278
  </DialogContent>
265
279
  <DialogActions>
266
280
  <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>