@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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +4 -0
  3. package/src/schema.ts +6 -0
  4. package/src/state/__tests__/use-project-sync.test.tsx +315 -0
  5. package/src/state/use-project-state.ts +47 -222
  6. package/src/state/use-project-sync.ts +310 -0
  7. package/src/video/VideoEditor.tsx +331 -111
  8. package/src/video/__tests__/VideoEditor.test.tsx +279 -3
  9. package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
  10. package/src/video/__tests__/captionPositioning.test.tsx +435 -0
  11. package/src/video/__tests__/captionRepair.test.ts +26 -0
  12. package/src/video/__tests__/playback-clock.test.tsx +45 -0
  13. package/src/video/captionRepair.ts +13 -1
  14. package/src/video/playback-clock.ts +31 -0
  15. package/src/video/preview/CaptionPreview.tsx +298 -4
  16. package/src/video/preview/OverlayItemsLayer.tsx +36 -13
  17. package/src/video/preview/OverlayPropsModal.tsx +292 -0
  18. package/src/video/preview/PreviewPlayer.tsx +27 -6
  19. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
  20. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
  21. package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
  22. package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
  23. package/src/video/preview/captionDragState.ts +175 -0
  24. package/src/video/preview/overlay-prop-fields.ts +39 -0
  25. package/src/video/preview/useDragOverlay.ts +21 -1
  26. package/src/video/preview/useVideoPlayback.ts +12 -3
  27. package/src/video/timeline/AudioTrackRow.tsx +6 -8
  28. package/src/video/timeline/CaptionTrackRow.tsx +235 -0
  29. package/src/video/timeline/PlayheadLine.tsx +18 -0
  30. package/src/video/timeline/Scrubber.tsx +7 -5
  31. package/src/video/timeline/Timeline.tsx +94 -30
  32. package/src/video/timeline/TimelineContext.ts +2 -2
  33. package/src/video/timeline/TranscriptModal.tsx +10 -3
  34. package/src/video/timeline/TranscriptPanel.tsx +7 -1
  35. package/src/video/timeline/VisualTrackRow.tsx +21 -14
  36. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
  37. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
  38. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
  39. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
  40. package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
  41. package/src/video/timeline/makeCaptionEdit.ts +33 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
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
+ })