@bycrux/editor 0.10.0 → 0.11.0

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.
@@ -0,0 +1,241 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { createPlaybackClock } from '../../playback-clock'
4
+ import { TimelineContext, type TimelineContextValue } from '../TimelineContext'
5
+ import CaptionTrackRow from '../CaptionTrackRow'
6
+ import type { CaptionSegment } from '../../../schema'
7
+
8
+ // Content width matches totalDuration 1:1 (1000px / 100s) so a clientX delta
9
+ // of N px is exactly N/10 seconds — makes the drag-math assertions readable.
10
+ const RECT = {
11
+ width: 1000, height: 40, left: 0, top: 0, right: 1000, bottom: 40, x: 0, y: 0,
12
+ toJSON: () => ({}),
13
+ } as DOMRect
14
+
15
+ function makeCtx(overrides: Partial<TimelineContextValue> = {}): TimelineContextValue {
16
+ return {
17
+ totalDuration: 100,
18
+ contentDuration: 100,
19
+ snapBoundaries: [],
20
+ zoom: 1,
21
+ zoomRef: { current: 1 },
22
+ scrollRef: { current: { getBoundingClientRect: () => RECT } as unknown as HTMLDivElement },
23
+ scrubberRef: { current: null },
24
+ overlayDraggedRef: { current: false },
25
+ clock: createPlaybackClock(),
26
+ markers: [null, null],
27
+ setMarkers: () => {},
28
+ selection: null,
29
+ ...overrides,
30
+ }
31
+ }
32
+
33
+ const seg = (over: Partial<CaptionSegment> = {}): CaptionSegment => ({
34
+ id: 'cap-0', text: 'hello world', start: 10, end: 20, ...over,
35
+ })
36
+
37
+ test('empty state renders when captionTrack is absent', () => {
38
+ render(
39
+ <TimelineContext.Provider value={makeCtx()}>
40
+ <CaptionTrackRow captionTrack={undefined} fps={30} selectedCaptionId={null} />
41
+ </TimelineContext.Provider>,
42
+ )
43
+ expect(screen.getByText('Captions')).toBeTruthy()
44
+ })
45
+
46
+ test('empty state renders when captionTrack has zero segments', () => {
47
+ render(
48
+ <TimelineContext.Provider value={makeCtx()}>
49
+ <CaptionTrackRow captionTrack={{ style: 'clean', segments: [] }} fps={30} selectedCaptionId={null} />
50
+ </TimelineContext.Provider>,
51
+ )
52
+ expect(screen.getByText('Captions')).toBeTruthy()
53
+ })
54
+
55
+ test('clicking a segment selects it and seeks the playhead into it', () => {
56
+ const ctx = makeCtx()
57
+ const onSelectCaption = vi.fn()
58
+ render(
59
+ <TimelineContext.Provider value={ctx}>
60
+ <CaptionTrackRow
61
+ captionTrack={{ style: 'clean', segments: [seg()] }}
62
+ fps={30}
63
+ selectedCaptionId={null}
64
+ onSelectCaption={onSelectCaption}
65
+ />
66
+ </TimelineContext.Provider>,
67
+ )
68
+ // The row itself (trackRow) also carries `cursor-pointer`, so anchor off the
69
+ // segment's text and walk up to its block instead of querying the class.
70
+ const block = screen.getByText('hello world').parentElement as HTMLDivElement
71
+ fireEvent.click(block)
72
+ expect(onSelectCaption).toHaveBeenCalledWith('cap-0')
73
+ expect(ctx.clock.get()).toBeCloseTo(10 + 0.5 / 30)
74
+ })
75
+
76
+ // Regression guard: the seek must survive CaptionPreview's frame quantization.
77
+ // The preview computes `t = Math.round(currentTime * fps) / fps` before running
78
+ // the templates' `t >= start && t < end` test, so seeking to a `start` that is
79
+ // not frame-aligned used to round DOWN into the PREVIOUS segment — the operator
80
+ // clicked a caption in the timeline and the preview drew its box over the one
81
+ // before it. Whisper emits arbitrary floats, so this hit about half of all
82
+ // segments.
83
+ test('the seek lands inside the clicked segment even when its start is not frame-aligned', () => {
84
+ const fps = 30
85
+ // 3.44 * 30 = 103.2 → frame 103 → t = 3.4333, which is BEFORE 3.44.
86
+ const segments: CaptionSegment[] = [
87
+ { id: 'cap-0', text: 'first', start: 0, end: 3.44 },
88
+ { id: 'cap-1', text: 'second', start: 3.44, end: 6.02 },
89
+ ]
90
+ const ctx = makeCtx()
91
+ render(
92
+ <TimelineContext.Provider value={ctx}>
93
+ <CaptionTrackRow
94
+ captionTrack={{ style: 'clean', segments }}
95
+ fps={fps}
96
+ selectedCaptionId={null}
97
+ onSelectCaption={vi.fn()}
98
+ />
99
+ </TimelineContext.Provider>,
100
+ )
101
+ fireEvent.click(screen.getByText('second').parentElement as HTMLDivElement)
102
+
103
+ const t = Math.round(ctx.clock.get() * fps) / fps
104
+ const active = segments.find((s) => t >= s.start && t < s.end)
105
+ expect(active?.id).toBe('cap-1')
106
+ })
107
+
108
+ // Regression guard: `steps/lyrics/caption.py` emits segments with no `id`
109
+ // field at all — ids are only minted later by VideoEditor's
110
+ // backfillCaptionIds effect. On first render `live` is null, so `live?.id`
111
+ // is undefined; an id-less segment's `seg.id` is also undefined, and
112
+ // undefined === undefined used to make `isLive` true and dereference `live!`
113
+ // while it was still null. This is the normal shape of every real project on
114
+ // its first paint, not an edge case.
115
+ test('renders without throwing when segments have no id field (pre-backfill shape)', () => {
116
+ const segments = [
117
+ { text: 'hello world', start: 10, end: 20 },
118
+ ] as unknown as CaptionSegment[]
119
+ expect(() =>
120
+ render(
121
+ <TimelineContext.Provider value={makeCtx()}>
122
+ <CaptionTrackRow
123
+ captionTrack={{ style: 'clean', segments }}
124
+ fps={30}
125
+ selectedCaptionId={null}
126
+ />
127
+ </TimelineContext.Provider>,
128
+ ),
129
+ ).not.toThrow()
130
+ })
131
+
132
+ describe('edge-drag retiming', () => {
133
+ // Regression guard for the single easiest way to break this feature: a
134
+ // retime patch must carry ONLY the dragged edge. If `text` ever leaked in,
135
+ // makeCaptionEdit would respread word timings and destroy per-word timing.
136
+ it('dragging the right edge sends { end } only — never text/words/other fields', () => {
137
+ const ctx = makeCtx()
138
+ const onCaptionSegmentChange = vi.fn()
139
+ const { container } = render(
140
+ <TimelineContext.Provider value={ctx}>
141
+ <CaptionTrackRow
142
+ captionTrack={{ style: 'clean', segments: [seg()] }}
143
+ fps={30}
144
+ selectedCaptionId={null}
145
+ onCaptionSegmentChange={onCaptionSegmentChange}
146
+ />
147
+ </TimelineContext.Provider>,
148
+ )
149
+ const handles = container.querySelectorAll('.cursor-ew-resize')
150
+ const rightHandle = handles[1] as HTMLDivElement
151
+
152
+ fireEvent.mouseDown(rightHandle, { clientX: 500 })
153
+ fireEvent.mouseMove(document, { clientX: 600 }) // +100px = +10s
154
+ // Not committed until mouseup — retiming must not fire on every tick
155
+ // (unlike VisualTrackRow, onCaptionSegmentChange pushes an undo entry and
156
+ // queues a save PER CALL).
157
+ expect(onCaptionSegmentChange).not.toHaveBeenCalled()
158
+
159
+ fireEvent.mouseUp(document)
160
+ expect(onCaptionSegmentChange).toHaveBeenCalledTimes(1)
161
+ const [id, patch] = onCaptionSegmentChange.mock.calls[0]
162
+ expect(id).toBe('cap-0')
163
+ expect(patch).toEqual({ end: 30 })
164
+ expect(Object.keys(patch)).toEqual(['end'])
165
+ })
166
+
167
+ // `useItemDragDrop.beginResize` has no travel threshold (unlike `beginDrag`)
168
+ // and calls onCommit unconditionally on mouseup, so a bare click on the 6px
169
+ // edge handle used to commit the edge's unchanged value — one undo entry and
170
+ // one queued save for a project that did not change.
171
+ it('a press-and-release on an edge handle with no movement commits nothing', () => {
172
+ const ctx = makeCtx()
173
+ const onCaptionSegmentChange = vi.fn()
174
+ const { container } = render(
175
+ <TimelineContext.Provider value={ctx}>
176
+ <CaptionTrackRow
177
+ captionTrack={{ style: 'clean', segments: [seg()] }}
178
+ fps={30}
179
+ selectedCaptionId={null}
180
+ onCaptionSegmentChange={onCaptionSegmentChange}
181
+ />
182
+ </TimelineContext.Provider>,
183
+ )
184
+ const rightHandle = container.querySelectorAll('.cursor-ew-resize')[1] as HTMLDivElement
185
+
186
+ fireEvent.mouseDown(rightHandle, { clientX: 500 })
187
+ fireEvent.mouseUp(document)
188
+ expect(onCaptionSegmentChange).not.toHaveBeenCalled()
189
+ })
190
+
191
+ // A drag that returns to where it started is likewise a no-op — the edge
192
+ // value is what matters, not whether the pointer moved.
193
+ it('a drag that ends back at the original edge commits nothing', () => {
194
+ const ctx = makeCtx()
195
+ const onCaptionSegmentChange = vi.fn()
196
+ const { container } = render(
197
+ <TimelineContext.Provider value={ctx}>
198
+ <CaptionTrackRow
199
+ captionTrack={{ style: 'clean', segments: [seg()] }}
200
+ fps={30}
201
+ selectedCaptionId={null}
202
+ onCaptionSegmentChange={onCaptionSegmentChange}
203
+ />
204
+ </TimelineContext.Provider>,
205
+ )
206
+ const rightHandle = container.querySelectorAll('.cursor-ew-resize')[1] as HTMLDivElement
207
+
208
+ fireEvent.mouseDown(rightHandle, { clientX: 500 })
209
+ fireEvent.mouseMove(document, { clientX: 600 })
210
+ fireEvent.mouseMove(document, { clientX: 500 })
211
+ fireEvent.mouseUp(document)
212
+ expect(onCaptionSegmentChange).not.toHaveBeenCalled()
213
+ })
214
+
215
+ it('dragging the left edge sends { start } only', () => {
216
+ const ctx = makeCtx()
217
+ const onCaptionSegmentChange = vi.fn()
218
+ const { container } = render(
219
+ <TimelineContext.Provider value={ctx}>
220
+ <CaptionTrackRow
221
+ captionTrack={{ style: 'clean', segments: [seg()] }}
222
+ fps={30}
223
+ selectedCaptionId={null}
224
+ onCaptionSegmentChange={onCaptionSegmentChange}
225
+ />
226
+ </TimelineContext.Provider>,
227
+ )
228
+ const handles = container.querySelectorAll('.cursor-ew-resize')
229
+ const leftHandle = handles[0] as HTMLDivElement
230
+
231
+ fireEvent.mouseDown(leftHandle, { clientX: 500 })
232
+ fireEvent.mouseMove(document, { clientX: 400 }) // -100px = -10s
233
+ fireEvent.mouseUp(document)
234
+
235
+ expect(onCaptionSegmentChange).toHaveBeenCalledTimes(1)
236
+ const [id, patch] = onCaptionSegmentChange.mock.calls[0]
237
+ expect(id).toBe('cap-0')
238
+ expect(patch).toEqual({ start: 0 })
239
+ expect(Object.keys(patch)).toEqual(['start'])
240
+ })
241
+ })
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
3
+ import type { Project } from '../../../types'
4
+ import type { CaptionSegment } from '../../../schema'
5
+ import TranscriptModal from '../TranscriptModal'
6
+
7
+ afterEach(() => cleanup())
8
+
9
+ function makeProject(segments: CaptionSegment[]): Project {
10
+ return { id: 'p1', captions: { style: 'word-by-word', segments } } as unknown as Project
11
+ }
12
+
13
+ describe('TranscriptModal caption text edits', () => {
14
+ // Regression: makeCaptionEdit fires every callback it's given with the same
15
+ // updated project. TranscriptModal used to pass BOTH onProjectChange and
16
+ // onCaptionEdit into makeCaptionEdit for a text edit, and — at this
17
+ // component's real call site (VideoEditor's ReviewSurface, via Timeline) —
18
+ // both land on sync.mutate, so one text edit produced two undo entries and
19
+ // two queued saves. Only onCaptionEdit (the commit channel) should fire;
20
+ // onProjectChange is no longer even an accepted prop (see TranscriptModal.tsx).
21
+ it('a completed text edit produces exactly one commit, not two', () => {
22
+ const onCaptionEdit = vi.fn()
23
+ const project = makeProject([{ id: 'cap-0', text: 'hello world', start: 0, end: 2 }])
24
+ render(
25
+ <TranscriptModal
26
+ captionTrack={project.captions}
27
+ currentTime={0}
28
+ project={project}
29
+ onCaptionEdit={onCaptionEdit}
30
+ onClose={() => {}}
31
+ />,
32
+ )
33
+
34
+ const span = screen.getByText('hello world')
35
+ span.textContent = 'hello there'
36
+ fireEvent.blur(span)
37
+
38
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
39
+ expect(onCaptionEdit.mock.calls[0][0].captions.segments[0].text).toBe('hello there')
40
+ })
41
+ })
@@ -83,3 +83,25 @@ describe('TranscriptPanel caption color controls', () => {
83
83
  expect((screen.getByLabelText('Caption highlight color') as HTMLInputElement).value).toBe('#00ff00')
84
84
  })
85
85
  })
86
+
87
+ describe('TranscriptPanel caption text edits', () => {
88
+ // Regression: makeCaptionEdit fires every callback it's given with the same
89
+ // updated project. TranscriptPanel used to pass BOTH onProjectChange and
90
+ // onCaptionEdit into makeCaptionEdit for a text edit, and — at this
91
+ // component's real call site (VideoEditor's ReviewSurface) — both land on
92
+ // sync.mutate, so one text edit produced two undo entries and two queued
93
+ // saves. Only onCaptionEdit (the commit channel) should fire.
94
+ it('a completed text edit produces exactly one commit, not two', () => {
95
+ const { onCaptionEdit, onProjectChange } = renderPanel('karaoke', {
96
+ segments: [{ id: 'cap-0', text: 'hello world', start: 0, end: 2 }],
97
+ })
98
+
99
+ const span = screen.getByText('hello world')
100
+ span.textContent = 'hello there'
101
+ fireEvent.blur(span)
102
+
103
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
104
+ expect(onCaptionEdit.mock.calls[0][0].captions.segments[0].text).toBe('hello there')
105
+ expect(onProjectChange).not.toHaveBeenCalled()
106
+ })
107
+ })
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import type { Project } from '../../../types'
3
+ import type { CaptionSegment } from '../../../schema'
4
+ import { makeCaptionEdit } from '../makeCaptionEdit'
5
+
6
+ function seg(overrides: Partial<CaptionSegment> = {}): CaptionSegment {
7
+ return { id: 'cap-0', text: 'hello world', start: 0, end: 2, ...overrides }
8
+ }
9
+
10
+ function project(segments: CaptionSegment[]): Project {
11
+ return { id: 'p1', captions: { style: 'word-by-word', segments } } as unknown as Project
12
+ }
13
+
14
+ describe('makeCaptionEdit', () => {
15
+ it('is a no-op when project.captions is absent', () => {
16
+ const onProjectChange = vi.fn()
17
+ const onCaptionEdit = vi.fn()
18
+ const p = { id: 'p1' } as unknown as Project
19
+ makeCaptionEdit(0, p, onProjectChange, onCaptionEdit)('new text')
20
+ expect(onProjectChange).not.toHaveBeenCalled()
21
+ expect(onCaptionEdit).not.toHaveBeenCalled()
22
+ })
23
+
24
+ it('accepts a bare string, treated as { text }, and respreads words (index target)', () => {
25
+ const onProjectChange = vi.fn()
26
+ const p = project([seg()])
27
+ makeCaptionEdit(0, p, onProjectChange)('foo bar baz')
28
+ const updated = onProjectChange.mock.calls[0][0] as Project
29
+ const out = updated.captions!.segments[0]
30
+ expect(out.text).toBe('foo bar baz')
31
+ expect(out.words).toHaveLength(3)
32
+ expect(out.words![0]).toEqual({ word: 'foo', start: 0, end: 2 / 3 })
33
+ expect(out.words![2].end).toBeCloseTo(2)
34
+ })
35
+
36
+ it('addresses a segment by string id', () => {
37
+ const onProjectChange = vi.fn()
38
+ const p = project([seg({ id: 'cap-0' }), seg({ id: 'cap-1', text: 'second', start: 2, end: 4 })])
39
+ makeCaptionEdit('cap-1', p, onProjectChange)('renamed')
40
+ const updated = onProjectChange.mock.calls[0][0] as Project
41
+ expect(updated.captions!.segments[0].text).toBe('hello world') // untouched
42
+ expect(updated.captions!.segments[1].text).toBe('renamed')
43
+ })
44
+
45
+ it('applies a position-only patch without touching words', () => {
46
+ const onProjectChange = vi.fn()
47
+ const original = seg({ words: [{ word: 'hello', start: 0, end: 1 }, { word: 'world', start: 1, end: 2 }] })
48
+ const p = project([original])
49
+ makeCaptionEdit(0, p, onProjectChange)({ offsetX: 10, offsetY: -5 })
50
+ const out = onProjectChange.mock.calls[0][0].captions.segments[0] as CaptionSegment
51
+ expect(out.offsetX).toBe(10)
52
+ expect(out.offsetY).toBe(-5)
53
+ expect(out.text).toBe('hello world')
54
+ expect(out.words).toEqual(original.words)
55
+ })
56
+
57
+ it('applies a retime-only patch ({ start, end }) without touching words', () => {
58
+ const onProjectChange = vi.fn()
59
+ const original = seg({ words: [{ word: 'hello', start: 0, end: 1 }, { word: 'world', start: 1, end: 2 }] })
60
+ const p = project([original])
61
+ makeCaptionEdit(0, p, onProjectChange)({ start: 5, end: 9 })
62
+ const out = onProjectChange.mock.calls[0][0].captions.segments[0] as CaptionSegment
63
+ expect(out.start).toBe(5)
64
+ expect(out.end).toBe(9)
65
+ expect(out.words).toEqual(original.words)
66
+ })
67
+
68
+ it('respreads words against the NEW duration when text and start/end change together', () => {
69
+ const onProjectChange = vi.fn()
70
+ const p = project([seg({ start: 0, end: 2 })])
71
+ makeCaptionEdit(0, p, onProjectChange)({ text: 'foo bar', start: 10, end: 14 })
72
+ const out = onProjectChange.mock.calls[0][0].captions.segments[0] as CaptionSegment
73
+ expect(out.words).toEqual([
74
+ { word: 'foo', start: 10, end: 12 },
75
+ { word: 'bar', start: 12, end: 14 },
76
+ ])
77
+ })
78
+
79
+ it('does not let undefined keys in the patch clobber existing values', () => {
80
+ const onProjectChange = vi.fn()
81
+ const p = project([seg({ offsetX: 7 })])
82
+ makeCaptionEdit(0, p, onProjectChange)({ offsetX: undefined, scale: 1.5 })
83
+ const out = onProjectChange.mock.calls[0][0].captions.segments[0] as CaptionSegment
84
+ expect(out.offsetX).toBe(7)
85
+ expect(out.scale).toBe(1.5)
86
+ })
87
+
88
+ it('leaves other segments untouched', () => {
89
+ const onProjectChange = vi.fn()
90
+ const p = project([seg({ id: 'cap-0' }), seg({ id: 'cap-1', text: 'second' })])
91
+ makeCaptionEdit(0, p, onProjectChange)({ scale: 2 })
92
+ const segments = onProjectChange.mock.calls[0][0].captions.segments as CaptionSegment[]
93
+ expect(segments[1]).toEqual(p.captions!.segments[1])
94
+ })
95
+
96
+ it('is a no-op (no callback) when the numeric index is out of range', () => {
97
+ const onProjectChange = vi.fn()
98
+ const onCaptionEdit = vi.fn()
99
+ const p = project([seg()])
100
+ makeCaptionEdit(5, p, onProjectChange, onCaptionEdit)('x')
101
+ expect(onProjectChange).not.toHaveBeenCalled()
102
+ expect(onCaptionEdit).not.toHaveBeenCalled()
103
+ })
104
+
105
+ it('is a no-op (no callback) when the string id matches no segment', () => {
106
+ const onProjectChange = vi.fn()
107
+ const onCaptionEdit = vi.fn()
108
+ const p = project([seg({ id: 'cap-0' })])
109
+ makeCaptionEdit('cap-nope', p, onProjectChange, onCaptionEdit)({ scale: 1.2 })
110
+ expect(onProjectChange).not.toHaveBeenCalled()
111
+ expect(onCaptionEdit).not.toHaveBeenCalled()
112
+ })
113
+
114
+ it('calls both onProjectChange and onCaptionEdit with the updated project', () => {
115
+ const onProjectChange = vi.fn()
116
+ const onCaptionEdit = vi.fn()
117
+ const p = project([seg()])
118
+ makeCaptionEdit(0, p, onProjectChange, onCaptionEdit)({ scale: 1.5 })
119
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
120
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
121
+ expect(onProjectChange.mock.calls[0][0]).toBe(onCaptionEdit.mock.calls[0][0])
122
+ })
123
+ })
@@ -1,28 +1,51 @@
1
1
  import type { Project } from '../../types'
2
+ import type { CaptionSegment } from '../../schema'
3
+
4
+ /** Fields `makeCaptionEdit` can patch onto a single caption segment. */
5
+ export type CaptionEditPatch = Partial<CaptionSegment>
2
6
 
3
7
  export function makeCaptionEdit(
4
- globalIdx: number,
8
+ target: number | string,
5
9
  project: Project,
6
10
  onProjectChange?: (p: Project) => void,
7
11
  onCaptionEdit?: (p: Project) => void,
8
12
  ) {
9
- return (text: string) => {
13
+ return (patch: string | CaptionEditPatch) => {
10
14
  if (!project.captions) return
15
+ const segments = project.captions.segments
16
+ // `target` addresses a segment by index (legacy call sites) or by `id`
17
+ // (new callers). A non-matching target is a no-op — no update, no callback.
18
+ const idx = typeof target === 'number' ? target : segments.findIndex((s) => s.id === target)
19
+ if (idx < 0 || idx >= segments.length) return
20
+
21
+ // A bare string is sugar for `{ text }`, preserving EditableSegment's contract.
22
+ const patchObj: CaptionEditPatch = typeof patch === 'string' ? { text: patch } : patch
23
+ // Only keys explicitly present (and not undefined) may overwrite the
24
+ // segment — an omitted or undefined key must never clobber the existing value.
25
+ const definedPatch = Object.fromEntries(
26
+ Object.entries(patchObj).filter(([, v]) => v !== undefined),
27
+ ) as CaptionEditPatch
28
+
11
29
  const updated = {
12
30
  ...project,
13
31
  captions: {
14
32
  ...project.captions,
15
- segments: project.captions.segments.map((s, j) => {
16
- if (j !== globalIdx) return s
17
- const newWords = text.split(/\s+/).filter(Boolean)
18
- const segDur = s.end - s.start
33
+ segments: segments.map((s, j) => {
34
+ if (j !== idx) return s
35
+ const next: CaptionSegment = { ...s, ...definedPatch }
36
+ if (definedPatch.text === undefined) return next
37
+ // Respread words evenly across the segment duration. Applied AFTER the
38
+ // patch merge so a combined `{ text, start, end }` edit retimes words
39
+ // against the new duration, not the stale one.
40
+ const newWords = next.text.split(/\s+/).filter(Boolean)
41
+ const segDur = next.end - next.start
19
42
  const wordDur = segDur / (newWords.length || 1)
20
- const words = newWords.map((w, wi) => ({
43
+ next.words = newWords.map((w, wi) => ({
21
44
  word: w,
22
- start: s.start + wi * wordDur,
23
- end: s.start + (wi + 1) * wordDur,
45
+ start: next.start + wi * wordDur,
46
+ end: next.start + (wi + 1) * wordDur,
24
47
  }))
25
- return { ...s, text, words }
48
+ return next
26
49
  }),
27
50
  },
28
51
  }