@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
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -112,6 +112,10 @@ export type { TextFormattingToolbarProps } from './text/TextFormattingToolbar'
|
|
|
112
112
|
export { OverlayPreview } from './preview/OverlayPreview'
|
|
113
113
|
export type { OverlayPreviewProps } from './preview/OverlayPreview'
|
|
114
114
|
|
|
115
|
+
// ── Playback clock (external playhead store) ──────────────────────────────────
|
|
116
|
+
export { createPlaybackClock, usePlaybackTime } from './video/playback-clock'
|
|
117
|
+
export type { PlaybackClock } from './video/playback-clock'
|
|
118
|
+
|
|
115
119
|
// ── Video preview ─────────────────────────────────────────────────────────────
|
|
116
120
|
export { default as PreviewPlayer } from './video/preview/PreviewPlayer'
|
|
117
121
|
export { default as CarouselPreview } from './video/preview/CarouselPreview'
|
package/src/schema.ts
CHANGED
|
@@ -40,6 +40,12 @@ export interface CaptionSegment {
|
|
|
40
40
|
start: number
|
|
41
41
|
end: number
|
|
42
42
|
words?: Word[]
|
|
43
|
+
offsetX?: number // percent of frame width, 0/absent = default anchor
|
|
44
|
+
offsetY?: number // percent of frame height, 0/absent = default anchor
|
|
45
|
+
// Visual scale of the whole caption block about its own centre — a CSS
|
|
46
|
+
// transform, not a font-size change, so it scales the background box and
|
|
47
|
+
// text stroke too and does NOT re-wrap the text. Default 1.
|
|
48
|
+
scale?: number
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
export interface Captions {
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
3
|
+
import { useProjectSync } from '../use-project-sync'
|
|
4
|
+
import type { EditorAdapter, Project, ImageElement, RenderEvent } from '../../types'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Fixtures — mirrors the use-project-state test's FakeAdapter, but drives the
|
|
8
|
+
// shape-agnostic core directly with `(p) => p` mutations instead of the typed
|
|
9
|
+
// action vocabulary.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
function makeProject(overrides: Partial<Project> = {}): Project {
|
|
13
|
+
return {
|
|
14
|
+
version: '1',
|
|
15
|
+
id: 'proj-1',
|
|
16
|
+
name: 'Test Project',
|
|
17
|
+
workflow: 'carousel',
|
|
18
|
+
status: 'draft',
|
|
19
|
+
editingPrompt: '',
|
|
20
|
+
settings: { resolution: [1080, 1080] },
|
|
21
|
+
assets: [],
|
|
22
|
+
slides: [
|
|
23
|
+
{
|
|
24
|
+
id: 'slide-0',
|
|
25
|
+
base_color: '#ffffff',
|
|
26
|
+
elements: [
|
|
27
|
+
{ id: 'el-0', type: 'image', src: 'a.png', x: 0, y: 0, w: 100, h: 100, rotation: 0 },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
...overrides,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface FakeAdapter extends EditorAdapter {
|
|
36
|
+
emit: (p: Project) => void
|
|
37
|
+
saveCalls: Array<{ id: string; project: Project }>
|
|
38
|
+
unsub: ReturnType<typeof vi.fn>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeFakeAdapter(opts: {
|
|
42
|
+
failSave?: boolean
|
|
43
|
+
/** When set, saveProject blocks on this until `release` is called. */
|
|
44
|
+
blockSave?: boolean
|
|
45
|
+
} = {}): FakeAdapter & { release: () => void } {
|
|
46
|
+
let onFrame: ((p: Project) => void) | null = null
|
|
47
|
+
const saveCalls: Array<{ id: string; project: Project }> = []
|
|
48
|
+
const unsub = vi.fn(() => { onFrame = null })
|
|
49
|
+
let releaseSave: (() => void) | null = null
|
|
50
|
+
const adapter: FakeAdapter & { release: () => void } = {
|
|
51
|
+
loadProject: vi.fn(async () => makeProject()),
|
|
52
|
+
saveProject: vi.fn(async (id: string, project: Project) => {
|
|
53
|
+
saveCalls.push({ id, project })
|
|
54
|
+
if (opts.blockSave) await new Promise<void>((res) => { releaseSave = res })
|
|
55
|
+
if (opts.failSave) throw new Error('save boom')
|
|
56
|
+
}),
|
|
57
|
+
subscribe: (_id: string, cb: (p: Project) => void) => {
|
|
58
|
+
onFrame = cb
|
|
59
|
+
return unsub
|
|
60
|
+
},
|
|
61
|
+
render: async function* (): AsyncIterable<RenderEvent> {
|
|
62
|
+
yield { type: 'done', outputPath: '/out.png' }
|
|
63
|
+
},
|
|
64
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
65
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
66
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
67
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
68
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
69
|
+
fileUrl: (path: string) => path,
|
|
70
|
+
emit: (p: Project) => onFrame?.(p),
|
|
71
|
+
saveCalls,
|
|
72
|
+
unsub,
|
|
73
|
+
release: () => releaseSave?.(),
|
|
74
|
+
}
|
|
75
|
+
return adapter
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}) })
|
|
79
|
+
afterEach(() => { vi.restoreAllMocks() })
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Tests
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
describe('useProjectSync — optimistic save', () => {
|
|
86
|
+
it('optimistically applies a mutation and persists via adapter.saveProject', async () => {
|
|
87
|
+
const adapter = makeFakeAdapter()
|
|
88
|
+
const initial = makeProject()
|
|
89
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
90
|
+
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await result.current.mutate((p) => ({ ...p, name: 'Renamed' }))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(result.current.project.name).toBe('Renamed')
|
|
96
|
+
expect(adapter.saveProject).toHaveBeenCalledTimes(1)
|
|
97
|
+
expect(adapter.saveCalls[0].id).toBe('proj-1')
|
|
98
|
+
expect(adapter.saveCalls[0].project.name).toBe('Renamed')
|
|
99
|
+
expect(result.current.canUndo).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('rolls back optimistic state and surfaces lastError when saveProject fails', async () => {
|
|
103
|
+
const adapter = makeFakeAdapter({ failSave: true })
|
|
104
|
+
const initial = makeProject()
|
|
105
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
106
|
+
|
|
107
|
+
await act(async () => {
|
|
108
|
+
await result.current.mutate((p) => ({ ...p, name: 'Renamed' })).catch(() => {})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Rolled back to the pre-mutation snapshot.
|
|
112
|
+
expect(result.current.project.name).toBe('Test Project')
|
|
113
|
+
expect(result.current.lastError).toMatch(/save boom/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('clearError resets lastError', async () => {
|
|
117
|
+
const adapter = makeFakeAdapter({ failSave: true })
|
|
118
|
+
const initial = makeProject()
|
|
119
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
120
|
+
|
|
121
|
+
await act(async () => {
|
|
122
|
+
await result.current.mutate((p) => ({ ...p, name: 'X' })).catch(() => {})
|
|
123
|
+
})
|
|
124
|
+
expect(result.current.lastError).toMatch(/save boom/)
|
|
125
|
+
|
|
126
|
+
act(() => { result.current.clearError() })
|
|
127
|
+
expect(result.current.lastError).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('useProjectSync — external frames', () => {
|
|
132
|
+
it('applies SSE frames pushed through adapter.subscribe and goes live', async () => {
|
|
133
|
+
const adapter = makeFakeAdapter()
|
|
134
|
+
const initial = makeProject()
|
|
135
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
136
|
+
|
|
137
|
+
expect(result.current.connection).toBe('connecting')
|
|
138
|
+
|
|
139
|
+
act(() => { adapter.emit(makeProject({ name: 'From Server' })) })
|
|
140
|
+
|
|
141
|
+
await waitFor(() => {
|
|
142
|
+
expect(result.current.project.name).toBe('From Server')
|
|
143
|
+
expect(result.current.connection).toBe('live')
|
|
144
|
+
})
|
|
145
|
+
// External frames do not save or push undo history.
|
|
146
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
147
|
+
expect(result.current.canUndo).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('applyExternal replaces state without saving or pushing undo', async () => {
|
|
151
|
+
const adapter = makeFakeAdapter()
|
|
152
|
+
const initial = makeProject()
|
|
153
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
154
|
+
|
|
155
|
+
act(() => { result.current.applyExternal(makeProject({ name: 'Server Authored' })) })
|
|
156
|
+
|
|
157
|
+
expect(result.current.project.name).toBe('Server Authored')
|
|
158
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
159
|
+
expect(result.current.canUndo).toBe(false)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('routes external frames through a supplied reconcile function', async () => {
|
|
163
|
+
const adapter = makeFakeAdapter()
|
|
164
|
+
const initial = makeProject({ name: 'Local' })
|
|
165
|
+
// reconcile that ignores the incoming frame (keeps prev).
|
|
166
|
+
const reconcile = vi.fn((prev: Project, _next: Project) => prev)
|
|
167
|
+
const { result } = renderHook(() =>
|
|
168
|
+
useProjectSync(adapter, initial.id, initial, { reconcile }),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
act(() => { result.current.applyExternal(makeProject({ name: 'Incoming' })) })
|
|
172
|
+
|
|
173
|
+
expect(reconcile).toHaveBeenCalledTimes(1)
|
|
174
|
+
expect(result.current.project.name).toBe('Local')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('defers an SSE frame while a save is in flight, then applies it on drain', async () => {
|
|
178
|
+
const adapter = makeFakeAdapter({ blockSave: true })
|
|
179
|
+
const initial = makeProject()
|
|
180
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
181
|
+
|
|
182
|
+
// Kick a mutation whose save blocks — the queue is now pending.
|
|
183
|
+
let mutatePromise: Promise<void> = Promise.resolve()
|
|
184
|
+
act(() => {
|
|
185
|
+
mutatePromise = result.current.mutate((p) => ({ ...p, name: 'Optimistic' }))
|
|
186
|
+
})
|
|
187
|
+
expect(result.current.project.name).toBe('Optimistic')
|
|
188
|
+
await waitFor(() => expect(adapter.saveProject).toHaveBeenCalledTimes(1))
|
|
189
|
+
|
|
190
|
+
// An echo arrives mid-save — it must be HELD, not applied (no clobber).
|
|
191
|
+
act(() => { adapter.emit(makeProject({ name: 'Echo While Saving' })) })
|
|
192
|
+
expect(result.current.project.name).toBe('Optimistic')
|
|
193
|
+
|
|
194
|
+
// Release the save; the queue drains and the deferred frame is applied.
|
|
195
|
+
await act(async () => {
|
|
196
|
+
adapter.release()
|
|
197
|
+
await mutatePromise
|
|
198
|
+
})
|
|
199
|
+
await waitFor(() => expect(result.current.project.name).toBe('Echo While Saving'))
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('useProjectSync — undo/redo', () => {
|
|
204
|
+
it('undo/redo swap state and re-persist via adapter', async () => {
|
|
205
|
+
const adapter = makeFakeAdapter()
|
|
206
|
+
const initial = makeProject()
|
|
207
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
208
|
+
|
|
209
|
+
await act(async () => {
|
|
210
|
+
await result.current.mutate((p) => ({ ...p, name: 'Renamed' }))
|
|
211
|
+
})
|
|
212
|
+
expect(result.current.project.name).toBe('Renamed')
|
|
213
|
+
expect(result.current.canUndo).toBe(true)
|
|
214
|
+
expect(result.current.canRedo).toBe(false)
|
|
215
|
+
|
|
216
|
+
await act(async () => { result.current.undo() })
|
|
217
|
+
expect(result.current.project.name).toBe('Test Project')
|
|
218
|
+
expect(result.current.canRedo).toBe(true)
|
|
219
|
+
|
|
220
|
+
await act(async () => { result.current.redo() })
|
|
221
|
+
expect(result.current.project.name).toBe('Renamed')
|
|
222
|
+
|
|
223
|
+
// mutate save + undo save + redo save
|
|
224
|
+
await waitFor(() => expect(adapter.saveProject).toHaveBeenCalledTimes(3))
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('undo is a no-op with an empty history', async () => {
|
|
228
|
+
const adapter = makeFakeAdapter()
|
|
229
|
+
const initial = makeProject()
|
|
230
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
231
|
+
|
|
232
|
+
act(() => { result.current.undo() })
|
|
233
|
+
expect(result.current.project.name).toBe('Test Project')
|
|
234
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('useProjectSync — transient gestures', () => {
|
|
239
|
+
it('commit persists one save after transient mutations, with one undo step', async () => {
|
|
240
|
+
const adapter = makeFakeAdapter()
|
|
241
|
+
const initial = makeProject()
|
|
242
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
243
|
+
|
|
244
|
+
await act(async () => {
|
|
245
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'Drag 1' }))
|
|
246
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'Drag 2' }))
|
|
247
|
+
})
|
|
248
|
+
// Transient mutations do not save.
|
|
249
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
250
|
+
expect(result.current.project.name).toBe('Drag 2')
|
|
251
|
+
|
|
252
|
+
await act(async () => { await result.current.commit() })
|
|
253
|
+
expect(adapter.saveProject).toHaveBeenCalledTimes(1)
|
|
254
|
+
expect(adapter.saveCalls[0].project.name).toBe('Drag 2')
|
|
255
|
+
// One undo step for the whole gesture.
|
|
256
|
+
expect(result.current.canUndo).toBe(true)
|
|
257
|
+
|
|
258
|
+
await act(async () => { result.current.undo() })
|
|
259
|
+
expect(result.current.project.name).toBe('Test Project')
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('useProjectSync — stale baseline regression', () => {
|
|
264
|
+
// Regression for a data-loss bug: applyExternal (and undo/redo) replaced
|
|
265
|
+
// state but left `transientBaseline` pointing at a now-stale pre-gesture
|
|
266
|
+
// snapshot. A second gesture would then see a non-null baseline and skip
|
|
267
|
+
// re-baselining, so commit() pushed the STALE snapshot as the undo/rollback
|
|
268
|
+
// target — a later undo silently discarded the external change in between.
|
|
269
|
+
// Sequence: gesture 1 (baseline = initial) → external frame arrives →
|
|
270
|
+
// gesture 2 (must re-baseline to the external frame, not reuse the stale
|
|
271
|
+
// one) → commit → undo. Asserts undo lands on the EXTERNAL state, not the
|
|
272
|
+
// pre-gesture-1 state.
|
|
273
|
+
it('undo after a gesture that follows an external frame restores the external state, not the stale pre-gesture baseline', async () => {
|
|
274
|
+
const adapter = makeFakeAdapter()
|
|
275
|
+
const initial = makeProject({ name: 'Test Project' })
|
|
276
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
277
|
+
|
|
278
|
+
// Gesture 1 — baselines against the initial state.
|
|
279
|
+
act(() => {
|
|
280
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'Gesture 1' }))
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// External frame arrives mid-gesture (e.g. cancelOverlayEdit routing through
|
|
284
|
+
// applyExternal, or an SSE echo / caption regen / restoreVersion).
|
|
285
|
+
act(() => {
|
|
286
|
+
result.current.applyExternal(makeProject({ name: 'External' }))
|
|
287
|
+
})
|
|
288
|
+
expect(result.current.project.name).toBe('External')
|
|
289
|
+
|
|
290
|
+
// Gesture 2 — must baseline against 'External' (the current state), not the
|
|
291
|
+
// stale 'Test Project' baseline from gesture 1.
|
|
292
|
+
act(() => {
|
|
293
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'Gesture 2' }))
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
await act(async () => { await result.current.commit() })
|
|
297
|
+
|
|
298
|
+
await act(async () => { result.current.undo() })
|
|
299
|
+
|
|
300
|
+
// Undo should remove only gesture 2, landing back on the external state —
|
|
301
|
+
// NOT the stale pre-gesture-1 snapshot ('Test Project'), which would
|
|
302
|
+
// silently discard the external change.
|
|
303
|
+
expect(result.current.project.name).toBe('External')
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
describe('useProjectSync — subscription lifecycle', () => {
|
|
308
|
+
it('unsubscribes on unmount', () => {
|
|
309
|
+
const adapter = makeFakeAdapter()
|
|
310
|
+
const initial = makeProject()
|
|
311
|
+
const { unmount } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
312
|
+
unmount()
|
|
313
|
+
expect(adapter.unsub).toHaveBeenCalledTimes(1)
|
|
314
|
+
})
|
|
315
|
+
})
|