@bycrux/editor 0.9.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.
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/schema.ts +6 -0
- package/src/state/__tests__/use-project-sync.test.tsx +315 -0
- package/src/state/use-project-state.ts +47 -222
- package/src/state/use-project-sync.ts +310 -0
- package/src/video/VideoEditor.tsx +331 -111
- package/src/video/__tests__/VideoEditor.test.tsx +279 -3
- package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
- package/src/video/__tests__/captionPositioning.test.tsx +435 -0
- package/src/video/__tests__/captionRepair.test.ts +26 -0
- package/src/video/__tests__/playback-clock.test.tsx +45 -0
- package/src/video/captionRepair.ts +13 -1
- package/src/video/playback-clock.ts +31 -0
- package/src/video/preview/CaptionPreview.tsx +298 -4
- package/src/video/preview/OverlayItemsLayer.tsx +36 -13
- package/src/video/preview/OverlayPropsModal.tsx +292 -0
- package/src/video/preview/PreviewPlayer.tsx +27 -6
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
- package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
- package/src/video/preview/captionDragState.ts +175 -0
- package/src/video/preview/overlay-prop-fields.ts +39 -0
- package/src/video/preview/useDragOverlay.ts +21 -1
- package/src/video/preview/useVideoPlayback.ts +12 -3
- package/src/video/timeline/AudioTrackRow.tsx +6 -8
- package/src/video/timeline/CaptionTrackRow.tsx +235 -0
- package/src/video/timeline/PlayheadLine.tsx +18 -0
- package/src/video/timeline/Scrubber.tsx +7 -5
- package/src/video/timeline/Timeline.tsx +94 -30
- package/src/video/timeline/TimelineContext.ts +2 -2
- package/src/video/timeline/TranscriptModal.tsx +10 -3
- package/src/video/timeline/TranscriptPanel.tsx +7 -1
- package/src/video/timeline/VisualTrackRow.tsx +21 -14
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
- package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
- package/src/video/timeline/makeCaptionEdit.ts +33 -10
|
@@ -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,60 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import { render, act } from '@testing-library/react'
|
|
3
|
+
import { createPlaybackClock } from '../../playback-clock'
|
|
4
|
+
import { TimelineContext, type TimelineContextValue } from '../TimelineContext'
|
|
5
|
+
import PlayheadLine from '../PlayheadLine'
|
|
6
|
+
|
|
7
|
+
function makeCtx(overrides: Partial<TimelineContextValue> = {}): TimelineContextValue {
|
|
8
|
+
return {
|
|
9
|
+
totalDuration: 10,
|
|
10
|
+
contentDuration: 10,
|
|
11
|
+
snapBoundaries: [],
|
|
12
|
+
zoom: 1,
|
|
13
|
+
zoomRef: { current: 1 },
|
|
14
|
+
scrollRef: { current: null },
|
|
15
|
+
scrubberRef: { current: null },
|
|
16
|
+
overlayDraggedRef: { current: false },
|
|
17
|
+
clock: createPlaybackClock(),
|
|
18
|
+
markers: [null, null],
|
|
19
|
+
setMarkers: () => {},
|
|
20
|
+
selection: null,
|
|
21
|
+
...overrides,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('PlayheadLine moves on clock.set without re-rendering a sibling row', () => {
|
|
26
|
+
const ctx = makeCtx()
|
|
27
|
+
let siblingRenders = 0
|
|
28
|
+
|
|
29
|
+
function Sibling() {
|
|
30
|
+
siblingRenders++
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
function Root() {
|
|
34
|
+
return (
|
|
35
|
+
<TimelineContext.Provider value={ctx}>
|
|
36
|
+
<PlayheadLine />
|
|
37
|
+
<Sibling />
|
|
38
|
+
</TimelineContext.Provider>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { container } = render(<Root />)
|
|
43
|
+
const before = siblingRenders
|
|
44
|
+
|
|
45
|
+
act(() => { ctx.clock.set(4) })
|
|
46
|
+
|
|
47
|
+
const line = container.querySelector('div') as HTMLDivElement
|
|
48
|
+
expect(line.style.left).toBe('40%')
|
|
49
|
+
expect(siblingRenders).toBe(before)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('renders nothing when totalDuration is 0', () => {
|
|
53
|
+
const ctx = makeCtx({ totalDuration: 0 })
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<TimelineContext.Provider value={ctx}>
|
|
56
|
+
<PlayheadLine />
|
|
57
|
+
</TimelineContext.Provider>,
|
|
58
|
+
)
|
|
59
|
+
expect(container.firstChild).toBeNull()
|
|
60
|
+
})
|
|
@@ -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
|
-
|
|
8
|
+
target: number | string,
|
|
5
9
|
project: Project,
|
|
6
10
|
onProjectChange?: (p: Project) => void,
|
|
7
11
|
onCaptionEdit?: (p: Project) => void,
|
|
8
12
|
) {
|
|
9
|
-
return (
|
|
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:
|
|
16
|
-
if (j !==
|
|
17
|
-
const
|
|
18
|
-
|
|
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
|
-
|
|
43
|
+
next.words = newWords.map((w, wi) => ({
|
|
21
44
|
word: w,
|
|
22
|
-
start:
|
|
23
|
-
end:
|
|
45
|
+
start: next.start + wi * wordDur,
|
|
46
|
+
end: next.start + (wi + 1) * wordDur,
|
|
24
47
|
}))
|
|
25
|
-
return
|
|
48
|
+
return next
|
|
26
49
|
}),
|
|
27
50
|
},
|
|
28
51
|
}
|