@bycrux/editor 0.12.0 → 1.0.1

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 (266) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +586 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +6 -2
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2202 -251
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,213 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * CaptionSpecimen tests. jsdom never lays anything out, so ResizeObserver
4
+ * never fires on its own — a no-op stub (mirrors VideoEditor.test.tsx) is
5
+ * installed before every render, which also exercises the "measurement
6
+ * never happens" floor-clamp path: the specimen word must still render at a
7
+ * legible size when `measuredWidth` stays 0 forever.
8
+ */
9
+ import { describe, it, expect, afterEach, beforeEach } from 'vitest'
10
+ import { render, cleanup } from '@testing-library/react'
11
+ import type { Captions } from '../../schema'
12
+ import CaptionSpecimen from '../CaptionSpecimen'
13
+
14
+ beforeEach(() => {
15
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
16
+ observe() {}
17
+ unobserve() {}
18
+ disconnect() {}
19
+ }
20
+ })
21
+
22
+ afterEach(() => cleanup())
23
+
24
+ const BALOO_2 = '"Baloo 2", system-ui, sans-serif'
25
+
26
+ function captions(over: Partial<Captions> = {}): Captions {
27
+ return {
28
+ style: 'word-by-word',
29
+ fontsize: 46,
30
+ segments: [
31
+ {
32
+ id: 'seg-1',
33
+ text: 'hello world',
34
+ start: 0,
35
+ end: 2,
36
+ words: [
37
+ { word: 'hello', start: 0, end: 1 },
38
+ { word: 'world', start: 1, end: 2 },
39
+ ],
40
+ },
41
+ ],
42
+ ...over,
43
+ }
44
+ }
45
+
46
+ describe('CaptionSpecimen — active word', () => {
47
+ it('renders the word under the playhead', () => {
48
+ const { getByTestId } = render(
49
+ <CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} />,
50
+ )
51
+ expect(getByTestId('caption-specimen-word').textContent).toBe('hello')
52
+ })
53
+
54
+ it('carries fontFamily, textTransform and letterSpacing on the word element', () => {
55
+ const { getByTestId } = render(
56
+ <CaptionSpecimen
57
+ captions={captions()}
58
+ currentTime={0.5}
59
+ fontSize={46}
60
+ fontFamily={BALOO_2}
61
+ textTransform="uppercase"
62
+ letterSpacing="0.02em"
63
+ />,
64
+ )
65
+ const word = getByTestId('caption-specimen-word')
66
+ expect(word.style.fontFamily).toBe(BALOO_2)
67
+ expect(word.style.textTransform).toBe('uppercase')
68
+ expect(word.style.letterSpacing).toBe('0.02em')
69
+ })
70
+
71
+ it('carries fontWeight on the word element', () => {
72
+ const { getByTestId } = render(
73
+ <CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} fontWeight={400} />,
74
+ )
75
+ expect(getByTestId('caption-specimen-word').style.fontWeight).toBe('400')
76
+ })
77
+
78
+ // An absent fontWeight/fontFamily must resolve to the ACTIVE STYLE's own
79
+ // default, not render blank / the editor UI's own font — that's the whole
80
+ // point of captionStyleDefaults.ts. Two styles with different weights
81
+ // (subtitle 600 vs outline 900) prove the resolution is keyed off the
82
+ // right style, not a single hardcoded fallback.
83
+ it('resolves an absent fontWeight to the active style\'s own default', () => {
84
+ const { getByTestId, rerender } = render(
85
+ <CaptionSpecimen captions={captions({ style: 'subtitle' })} currentTime={0.5} fontSize={46} />,
86
+ )
87
+ expect(getByTestId('caption-specimen-word').style.fontWeight).toBe('600')
88
+
89
+ rerender(<CaptionSpecimen captions={captions({ style: 'outline' })} currentTime={0.5} fontSize={46} />)
90
+ expect(getByTestId('caption-specimen-word').style.fontWeight).toBe('900')
91
+ })
92
+
93
+ it('resolves an absent fontFamily to the active style\'s own default', () => {
94
+ const { getByTestId, rerender } = render(
95
+ <CaptionSpecimen captions={captions({ style: 'clean' })} currentTime={0.5} fontSize={46} />,
96
+ )
97
+ expect(getByTestId('caption-specimen-word').style.fontFamily).toBe('"Figtree", system-ui, sans-serif')
98
+
99
+ rerender(<CaptionSpecimen captions={captions({ style: 'outline' })} currentTime={0.5} fontSize={46} />)
100
+ expect(getByTestId('caption-specimen-word').style.fontFamily).toBe('system-ui, -apple-system, sans-serif')
101
+ })
102
+
103
+ // An EXPLICIT fontWeight always wins over the style default, e.g. the Bold
104
+ // toggle's "off" state (a stored 400) on a style that would otherwise
105
+ // default to 900.
106
+ it('an explicit fontWeight overrides the style default', () => {
107
+ const { getByTestId } = render(
108
+ <CaptionSpecimen captions={captions({ style: 'outline' })} currentTime={0.5} fontSize={46} fontWeight={400} />,
109
+ )
110
+ expect(getByTestId('caption-specimen-word').style.fontWeight).toBe('400')
111
+ })
112
+
113
+ it('does not set textTransform/letterSpacing when they are not passed', () => {
114
+ const { getByTestId } = render(
115
+ <CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} />,
116
+ )
117
+ const word = getByTestId('caption-specimen-word')
118
+ expect(word.style.textTransform).toBe('')
119
+ expect(word.style.letterSpacing).toBe('')
120
+ })
121
+
122
+ it('renders the word at a legible size even though jsdom never measures a width', () => {
123
+ const { getByTestId } = render(
124
+ <CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} />,
125
+ )
126
+ const px = parseFloat(getByTestId('caption-specimen-word').style.fontSize)
127
+ expect(px).toBeGreaterThanOrEqual(11)
128
+ })
129
+ })
130
+
131
+ describe('CaptionSpecimen — family label', () => {
132
+ it('resolves a recognized family via findFontOption', () => {
133
+ const { getByText } = render(
134
+ <CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} fontFamily={BALOO_2} />,
135
+ )
136
+ expect(getByText('Baloo 2')).toBeTruthy()
137
+ })
138
+
139
+ it('shows Default when fontFamily is unset', () => {
140
+ const { getByText } = render(
141
+ <CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} />,
142
+ )
143
+ expect(getByText('Default')).toBeTruthy()
144
+ })
145
+
146
+ it('shows Custom for an unrecognized family', () => {
147
+ const { getByText } = render(
148
+ <CaptionSpecimen
149
+ captions={captions()}
150
+ currentTime={0.5}
151
+ fontSize={46}
152
+ fontFamily='"Some Unlisted Font", sans-serif'
153
+ />,
154
+ )
155
+ expect(getByText('Custom')).toBeTruthy()
156
+ })
157
+ })
158
+
159
+ describe('CaptionSpecimen — size label', () => {
160
+ it('shows the real caption render size, not the scaled-down on-screen size', () => {
161
+ const { getByText } = render(
162
+ <CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} />,
163
+ )
164
+ expect(getByText('46px')).toBeTruthy()
165
+ })
166
+ })
167
+
168
+ describe('CaptionSpecimen — no active word', () => {
169
+ it('shows the empty-state line, not a placeholder glyph, when the playhead is outside every segment and nothing is selected', () => {
170
+ const { getByTestId, queryByText } = render(
171
+ <CaptionSpecimen captions={captions()} currentTime={10} fontSize={46} />,
172
+ )
173
+ expect(getByTestId('caption-specimen-word').textContent).toBe(
174
+ 'Move the playhead over a caption to preview it',
175
+ )
176
+ expect(queryByText('Aa')).toBeNull()
177
+ })
178
+
179
+ it('still shows the family and size labels in the empty state', () => {
180
+ const { getByText } = render(
181
+ <CaptionSpecimen captions={captions()} currentTime={10} fontSize={46} fontFamily={BALOO_2} />,
182
+ )
183
+ expect(getByText('Baloo 2')).toBeTruthy()
184
+ expect(getByText('46px')).toBeTruthy()
185
+ })
186
+ })
187
+
188
+ describe('CaptionSpecimen — selected-segment fallback', () => {
189
+ it('shows the selected segment\'s first word, plus a "not live" tag, when the playhead is outside every segment', () => {
190
+ const { getByTestId, getByText } = render(
191
+ <CaptionSpecimen
192
+ captions={captions()}
193
+ currentTime={10}
194
+ selectedSegmentId="seg-1"
195
+ fontSize={46}
196
+ />,
197
+ )
198
+ expect(getByTestId('caption-specimen-word').textContent).toBe('hello')
199
+ expect(getByText('Selected caption')).toBeTruthy()
200
+ })
201
+
202
+ it('does not show the fallback tag when the word is live under the playhead', () => {
203
+ const { queryByText } = render(
204
+ <CaptionSpecimen
205
+ captions={captions()}
206
+ currentTime={0.5}
207
+ selectedSegmentId="seg-1"
208
+ fontSize={46}
209
+ />,
210
+ )
211
+ expect(queryByText('Selected caption')).toBeNull()
212
+ })
213
+ })
@@ -0,0 +1,362 @@
1
+ // CaptionStyleGallery.test.tsx
2
+ //
3
+ // Covers the five behaviours the Style-tab gallery owes its caller:
4
+ // 1. one card per style, each with a real accessible name
5
+ // 2. clicking a card commits `captions.style` through `onCaptionEdit`
6
+ // 3. the active style's card reads selected
7
+ // 4. hovering starts the frame advance; unhovering stops it and resets
8
+ // 5. a host with no `compileOverlay` degrades to the static specimen
9
+ //
10
+ // Two pieces of the environment are faked, both deliberately:
11
+ //
12
+ // - `requestAnimationFrame` / `cancelAnimationFrame`. The card's loop is
13
+ // driven off the rAF TIMESTAMP, so handing it chosen timestamps makes the
14
+ // frame it computes exact and assertable, instead of racing real frames.
15
+ // `cancelAnimationFrame` really removes the pending callback, which is what
16
+ // lets the unhover half of test 4 mean anything.
17
+ //
18
+ // - `compileOverlay`, which returns a factory that records every call AND
19
+ // renders a node carrying the frame it was given. The DOM assertion is the
20
+ // guard against a vacuous test: jsdom lays nothing out, so if the card had
21
+ // copied CaptionPreview's `scale === null` gate it would render nothing at
22
+ // all and a call-count-only assertion would pass while the gallery was
23
+ // blank. See FALLBACK_CARD_W in CaptionStyleGallery.tsx.
24
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
25
+ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
26
+ import type { Captions } from '../../schema'
27
+ import type { OverlayFactory, Project } from '../../types'
28
+ import CaptionStyleGallery, {
29
+ CAPTION_STYLES,
30
+ CAPTION_STYLE_LABELS,
31
+ SAMPLE_SEGMENT,
32
+ } from '../CaptionStyleGallery'
33
+
34
+ // ── rAF harness ─────────────────────────────────────────────────────────────
35
+ let pendingFrames: Map<number, FrameRequestCallback>
36
+ let nextFrameId: number
37
+
38
+ /** Run every callback currently scheduled, at `now` ms. Callbacks that
39
+ * re-schedule (the card's loop does) repopulate the queue for the next call. */
40
+ function flushFrame(now: number) {
41
+ const due = Array.from(pendingFrames.values())
42
+ pendingFrames.clear()
43
+ act(() => { due.forEach(cb => cb(now)) })
44
+ }
45
+
46
+ beforeEach(() => {
47
+ pendingFrames = new Map()
48
+ nextFrameId = 1
49
+ vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
50
+ const id = nextFrameId++
51
+ pendingFrames.set(id, cb)
52
+ return id
53
+ })
54
+ vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((id: number) => {
55
+ pendingFrames.delete(id)
56
+ })
57
+ })
58
+
59
+ afterEach(() => {
60
+ cleanup()
61
+ vi.restoreAllMocks()
62
+ })
63
+
64
+ // ── Fixtures ────────────────────────────────────────────────────────────────
65
+ interface RecordedCall {
66
+ src: string
67
+ frame: number
68
+ fps: number
69
+ totalFrames: number
70
+ props: Record<string, unknown>
71
+ }
72
+
73
+ function makeProject(captions: Captions): Project {
74
+ return { id: 'p1', captions } as unknown as Project
75
+ }
76
+
77
+ async function renderGallery(opts: {
78
+ style?: Captions['style']
79
+ extra?: Partial<Captions>
80
+ withCompiler?: boolean
81
+ } = {}) {
82
+ const { style = 'karaoke', extra = {}, withCompiler = true } = opts
83
+
84
+ const calls: RecordedCall[] = []
85
+ // One factory per template src, so a card's calls are attributable to it.
86
+ // `resolveCaptionTemplate` is identity below, so src === style name.
87
+ const compileOverlay = vi.fn(async (src: string): Promise<OverlayFactory> => {
88
+ return (frame, fps, totalFrames, props) => {
89
+ calls.push({ src, frame, fps, totalFrames, props })
90
+ return <div data-testid={`live-${src}`} data-frame={String(frame)} />
91
+ }
92
+ })
93
+ const resolveCaptionTemplate = vi.fn((s: string) => s)
94
+
95
+ const captions: Captions = { style, segments: [], ...extra }
96
+ const project = makeProject(captions)
97
+ const onCaptionEdit = vi.fn()
98
+
99
+ const view = render(
100
+ <CaptionStyleGallery
101
+ captions={captions}
102
+ project={project}
103
+ onCaptionEdit={onCaptionEdit}
104
+ compileOverlay={withCompiler ? compileOverlay : undefined}
105
+ resolveCaptionTemplate={withCompiler ? resolveCaptionTemplate : undefined}
106
+ />,
107
+ )
108
+
109
+ // Let every card's compile resolve before any assertion, so no test races
110
+ // the async setFactory (and none of them leak an un-acted state update).
111
+ if (withCompiler) {
112
+ await waitFor(() => {
113
+ expect(document.querySelectorAll('[data-testid^="live-"]')).toHaveLength(CAPTION_STYLES.length)
114
+ })
115
+ }
116
+
117
+ return { view, calls, compileOverlay, resolveCaptionTemplate, captions, project, onCaptionEdit }
118
+ }
119
+
120
+ const cardFor = (style: Captions['style']) =>
121
+ screen.getByRole('button', { name: CAPTION_STYLE_LABELS[style] })
122
+
123
+ // ── Tests ───────────────────────────────────────────────────────────────────
124
+ describe('CaptionStyleGallery', () => {
125
+ it('renders one labelled card per style', async () => {
126
+ await renderGallery()
127
+
128
+ expect(screen.getAllByRole('button')).toHaveLength(CAPTION_STYLES.length)
129
+ for (const style of CAPTION_STYLES) {
130
+ expect(cardFor(style)).toHaveAttribute('data-style', style)
131
+ }
132
+ })
133
+
134
+ it('compiles every style template eagerly on mount, before any hover', async () => {
135
+ const { compileOverlay } = await renderGallery()
136
+
137
+ expect(compileOverlay).toHaveBeenCalledTimes(CAPTION_STYLES.length)
138
+ const compiled = compileOverlay.mock.calls.map(c => c[0]).sort()
139
+ expect(compiled).toEqual([...CAPTION_STYLES].sort())
140
+ })
141
+
142
+ it('feeds each template the synthetic sample and the track theme', async () => {
143
+ const { calls } = await renderGallery({ extra: { color: '#ff0000', fontsize: 46 } })
144
+
145
+ const popCall = calls.filter(c => c.src === 'pop')[0]
146
+ expect(popCall.props.segments).toEqual([SAMPLE_SEGMENT])
147
+ // Pin the sample's own shape too, not just that it round-trips: four
148
+ // contiguous words, each clearing pop.jsx's `wordDuration > 6` (frames)
149
+ // exit-fade guard at SAMPLE_FPS with room to spare.
150
+ const sampleWords = SAMPLE_SEGMENT.words ?? []
151
+ expect(SAMPLE_SEGMENT.text).toBe('The quick brown fox')
152
+ expect(sampleWords.map(w => w.word)).toEqual(['The', 'quick', 'brown', 'fox'])
153
+ expect(sampleWords.map(w => w.start)).toEqual([0, 0.65, 1.3, 1.95])
154
+ expect(sampleWords.map(w => w.end)).toEqual([0.65, 1.3, 1.95, 2.6])
155
+ for (const w of sampleWords) {
156
+ expect((w.end - w.start) * 30).toBeGreaterThan(6)
157
+ }
158
+ expect(popCall.fps).toBe(30)
159
+ // Track theme reaches the card...
160
+ expect(popCall.props.color).toBe('#ff0000')
161
+ // ...but the project's own font size does NOT: a card renders at a fixed,
162
+ // legible size (see CARD_FONT_SIZE) because the frame is scaled to ~1/8.
163
+ expect(popCall.props.fontSize).not.toBe(46)
164
+ expect(popCall.props.fontSize).toBeGreaterThan(46)
165
+ })
166
+
167
+ it('commits captions.style when a card is clicked', async () => {
168
+ const { onCaptionEdit, project } = await renderGallery({ style: 'karaoke' })
169
+
170
+ fireEvent.click(cardFor('pop'))
171
+
172
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
173
+ expect(onCaptionEdit.mock.calls[0][0]).toEqual({
174
+ ...project,
175
+ captions: { ...project.captions, style: 'pop' },
176
+ })
177
+ })
178
+
179
+ it('marks only the active style card as selected', async () => {
180
+ await renderGallery({ style: 'outline' })
181
+
182
+ expect(cardFor('outline')).toHaveAttribute('aria-pressed', 'true')
183
+ for (const style of CAPTION_STYLES.filter(s => s !== 'outline')) {
184
+ expect(cardFor(style)).toHaveAttribute('aria-pressed', 'false')
185
+ }
186
+ })
187
+
188
+ it('advances the frame while hovered and stops on unhover', async () => {
189
+ const { calls } = await renderGallery()
190
+
191
+ // The still every card rests at before any pointer interaction.
192
+ const posterFrame = calls.filter(c => c.src === 'pop')[0].frame
193
+ expect(screen.getByTestId('live-pop')).toHaveAttribute('data-frame', String(posterFrame))
194
+ expect(pendingFrames.size).toBe(0)
195
+
196
+ const card = cardFor('pop')
197
+ fireEvent.mouseEnter(card)
198
+ calls.length = 0
199
+
200
+ // Timestamps are chosen, not real: elapsed 0s / 0.4s / 0.9s at 30fps.
201
+ flushFrame(1000)
202
+ flushFrame(1400)
203
+ flushFrame(1900)
204
+
205
+ expect(calls.filter(c => c.src === 'pop').map(c => c.frame)).toEqual([0, 12, 27])
206
+ // Not vacuous: the advancing frame reached the DOM, so the card really is
207
+ // rendering the template rather than gating on an unmeasured scale.
208
+ expect(screen.getByTestId('live-pop')).toHaveAttribute('data-frame', '27')
209
+ // The loop is per-card, not per-gallery: an unhovered card stays put.
210
+ expect(calls.filter(c => c.src === 'karaoke')).toHaveLength(0)
211
+
212
+ fireEvent.mouseLeave(card)
213
+
214
+ // The loop is torn down — nothing is left scheduled — and the card snaps
215
+ // back to its poster frame rather than freezing mid-animation.
216
+ expect(pendingFrames.size).toBe(0)
217
+ expect(screen.getByTestId('live-pop')).toHaveAttribute('data-frame', String(posterFrame))
218
+
219
+ calls.length = 0
220
+ flushFrame(3000)
221
+ expect(calls).toHaveLength(0)
222
+ })
223
+
224
+ it('tears the frame loop down on unmount, leaving nothing scheduled', async () => {
225
+ const { view } = await renderGallery()
226
+
227
+ fireEvent.mouseEnter(cardFor('pop'))
228
+ flushFrame(1000)
229
+ expect(pendingFrames.size).toBe(1)
230
+
231
+ view.unmount()
232
+
233
+ expect(pendingFrames.size).toBe(0)
234
+ })
235
+
236
+ it('falls back to the static specimen when the host has no compiler', async () => {
237
+ await renderGallery({ withCompiler: false })
238
+
239
+ expect(screen.getAllByTestId('caption-specimen')).toHaveLength(CAPTION_STYLES.length)
240
+ expect(document.querySelectorAll('[data-testid^="live-"]')).toHaveLength(0)
241
+
242
+ // The specimen shows the sample's own word (POSTER_FRAME lands on the
243
+ // SECOND word, "quick" — see POSTER_FRAME in CaptionStyleGallery.tsx),
244
+ // not an empty-state prompt.
245
+ const outlineWord = within(cardFor('outline')).getByTestId('caption-specimen-word')
246
+ expect(outlineWord).toHaveTextContent('quick')
247
+ // ...and in THAT style's look: `outline` is the one style whose template
248
+ // defaults to uppercase (captionStyleDefaults.ts), and CaptionSpecimen has
249
+ // no fallback of its own for textTransform, so the `toHaveStyle` below
250
+ // only holds because the card applies the per-style default. The word
251
+ // itself is stored lower-case ("quick", not "QUICK") — `toHaveTextContent`
252
+ // reads the raw DOM text node, unaffected by CSS, so the CASE assertion
253
+ // above and the CSS `text-transform` assertion below are independent
254
+ // checks, not one relying on the other.
255
+ expect(outlineWord).toHaveStyle({ textTransform: 'uppercase' })
256
+ expect(within(cardFor('pop')).getByTestId('caption-specimen-word'))
257
+ .toHaveStyle({ letterSpacing: '-0.02em' })
258
+ })
259
+
260
+ it('commits captions.style from a fallback-mode card too', async () => {
261
+ // Deliberately the same assertion as the live-mode click test above, run
262
+ // against the compiler-less path. The click handler lives on the <button>
263
+ // that wraps BOTH the live frame and the specimen fallback, so this holds
264
+ // today — but nothing else in this file would catch a refactor that moved
265
+ // it inside the live branch, and every live-mode test would stay green
266
+ // while it happened.
267
+ //
268
+ // The fallback path IS the Hub / Los Parceros experience (neither host
269
+ // supplies `compileOverlay`), so that regression would ship a gallery
270
+ // where clicking a style does nothing on exactly the hosts nobody here
271
+ // exercises by hand.
272
+ const { onCaptionEdit, project } = await renderGallery({ style: 'karaoke', withCompiler: false })
273
+
274
+ fireEvent.click(cardFor('pop'))
275
+
276
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
277
+ expect(onCaptionEdit.mock.calls[0][0]).toEqual({
278
+ ...project,
279
+ captions: { ...project.captions, style: 'pop' },
280
+ })
281
+ })
282
+
283
+ it('falls back to the static specimen for a card whose compile rejects, while the rest stay live', async () => {
284
+ // A failed compile must not leave that one card permanently on the black
285
+ // `aspect-ratio` band with `element === null` — it should degrade to the
286
+ // same static specimen a no-compiler host gets, same as compileCached's
287
+ // eviction-on-reject already does for the module cache.
288
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
289
+
290
+ const compileOverlay = vi.fn(async (src: string): Promise<OverlayFactory> => {
291
+ if (src === 'outline') throw new Error('template fetch failed')
292
+ return (frame) => <div data-testid={`live-${src}`} data-frame={String(frame)} />
293
+ })
294
+ const resolveCaptionTemplate = vi.fn((s: string) => s)
295
+ const captions: Captions = { style: 'karaoke', segments: [] }
296
+ const project = makeProject(captions)
297
+
298
+ render(
299
+ <CaptionStyleGallery
300
+ captions={captions}
301
+ project={project}
302
+ onCaptionEdit={vi.fn()}
303
+ compileOverlay={compileOverlay}
304
+ resolveCaptionTemplate={resolveCaptionTemplate}
305
+ />,
306
+ )
307
+
308
+ // Six of the seven compiles resolve; `outline`'s rejects and never
309
+ // produces a `live-outline` node.
310
+ await waitFor(() => {
311
+ expect(document.querySelectorAll('[data-testid^="live-"]')).toHaveLength(CAPTION_STYLES.length - 1)
312
+ })
313
+
314
+ expect(screen.queryByTestId('live-outline')).not.toBeInTheDocument()
315
+ // Not vacuous: the failed card actually renders the specimen fallback,
316
+ // not just "no live node" (which an infinitely-pending compile would also
317
+ // produce).
318
+ expect(within(cardFor('outline')).getByTestId('caption-specimen')).toBeInTheDocument()
319
+
320
+ for (const style of CAPTION_STYLES.filter(s => s !== 'outline')) {
321
+ expect(within(cardFor(style)).getByTestId(`live-${style}`)).toBeInTheDocument()
322
+ }
323
+ })
324
+
325
+ it('advances the frame on focus and stops on blur, mirroring hover', async () => {
326
+ // Keyboard/touch/pen parity with the hover test above: the frame loop
327
+ // must also run off focus/blur, not just mouseenter/mouseleave, or a
328
+ // keyboard user tabbing through the grid never sees anything past the
329
+ // poster frame.
330
+ const { calls } = await renderGallery()
331
+
332
+ const posterFrame = calls.filter(c => c.src === 'pop')[0].frame
333
+ expect(screen.getByTestId('live-pop')).toHaveAttribute('data-frame', String(posterFrame))
334
+ expect(pendingFrames.size).toBe(0)
335
+
336
+ const card = cardFor('pop')
337
+ fireEvent.focus(card)
338
+ calls.length = 0
339
+
340
+ // Same chosen timestamps as the hover test: elapsed 0s / 0.4s / 0.9s at 30fps.
341
+ flushFrame(1000)
342
+ flushFrame(1400)
343
+ flushFrame(1900)
344
+
345
+ expect(calls.filter(c => c.src === 'pop').map(c => c.frame)).toEqual([0, 12, 27])
346
+ // Not vacuous: without the onFocus handler `hovered` never flips, so this
347
+ // would still read the poster frame and the assertion above would fail.
348
+ expect(screen.getByTestId('live-pop')).toHaveAttribute('data-frame', '27')
349
+ expect(calls.filter(c => c.src === 'karaoke')).toHaveLength(0)
350
+
351
+ fireEvent.blur(card)
352
+
353
+ // Torn down on blur, same as mouseleave, and reset to the poster frame
354
+ // rather than freezing mid-animation.
355
+ expect(pendingFrames.size).toBe(0)
356
+ expect(screen.getByTestId('live-pop')).toHaveAttribute('data-frame', String(posterFrame))
357
+
358
+ calls.length = 0
359
+ flushFrame(3000)
360
+ expect(calls).toHaveLength(0)
361
+ })
362
+ })