@bycrux/editor 0.11.2 → 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 (268) 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 +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  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 +601 -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 +137 -0
  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 +2229 -243
  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/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,165 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * SocialSafeZoneOverlay is a preview-only viewing aid (see the file-level
4
+ * doc comment on the component) — these tests cover exactly what the spec
5
+ * calls out: the chrome renders for a recognized platform, renders nothing
6
+ * for an absent/unrecognized one, stays pointer-events-none throughout, and
7
+ * scales off a measured container size rather than a fixed pixel value.
8
+ *
9
+ * jsdom does no layout, so ResizeObserver never fires on its own — each test
10
+ * installs a controllable stub (mirrors the pattern in
11
+ * `src/video/__tests__/captionPositioning.test.tsx`) that reports a size the
12
+ * test chooses, and can re-fire with a different size mid-test.
13
+ */
14
+ import { describe, it, expect, afterEach } from 'vitest'
15
+ import { render, cleanup, act } from '@testing-library/react'
16
+ import SocialSafeZoneOverlay from '../SocialSafeZoneOverlay'
17
+
18
+ afterEach(() => {
19
+ cleanup()
20
+ })
21
+
22
+ /** Installs a ResizeObserver stub that reports `initial` the moment
23
+ * `observe()` is called, and returns a `fire` helper to report a different
24
+ * size later — e.g. simulating a pane drag or fullscreen toggle. */
25
+ function installResizeObserver(initial: { width: number; height: number }) {
26
+ let cb: ((entries: { contentRect: { width: number; height: number } }[]) => void) | null = null
27
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
28
+ constructor(callback: typeof cb) { cb = callback }
29
+ observe() { cb?.([{ contentRect: initial }]) }
30
+ unobserve() {}
31
+ disconnect() {}
32
+ }
33
+ return { fire: (width: number, height: number) => cb?.([{ contentRect: { width, height } }]) }
34
+ }
35
+
36
+ describe('SocialSafeZoneOverlay — activation', () => {
37
+ it('renders nothing when no platform is given', () => {
38
+ installResizeObserver({ width: 1080, height: 1920 })
39
+ const { container } = render(<SocialSafeZoneOverlay />)
40
+ expect(container.firstChild).toBeNull()
41
+ })
42
+
43
+ it('renders nothing for an unrecognized platform', () => {
44
+ installResizeObserver({ width: 1080, height: 1920 })
45
+ const { container } = render(<SocialSafeZoneOverlay platform="reels" />)
46
+ expect(container.firstChild).toBeNull()
47
+ })
48
+ })
49
+
50
+ describe('SocialSafeZoneOverlay — TikTok chrome', () => {
51
+ it('renders the status bar, nav row, engagement rail and bottom-left credit block', () => {
52
+ installResizeObserver({ width: 1080, height: 1920 })
53
+ const { getByText, getAllByText } = render(<SocialSafeZoneOverlay platform="tiktok" />)
54
+
55
+ // Status bar
56
+ expect(getByText('9:41')).toBeTruthy()
57
+
58
+ // Nav row — For You is the active tab
59
+ expect(getByText('Following')).toBeTruthy()
60
+ expect(getByText('For You')).toBeTruthy()
61
+
62
+ // Right rail — one avatar + four counted actions, each labeled 2.8M
63
+ expect(getAllByText('2.8M')).toHaveLength(4)
64
+
65
+ // Bottom-left credit block
66
+ expect(getByText('@Your name')).toBeTruthy()
67
+ expect(getByText('Here are some descriptions about videos')).toBeTruthy()
68
+ expect(getByText('Music name')).toBeTruthy()
69
+
70
+ // Bottom app nav
71
+ expect(getByText('Home')).toBeTruthy()
72
+ expect(getByText('Profile')).toBeTruthy()
73
+ })
74
+ })
75
+
76
+ describe('SocialSafeZoneOverlay — YouTube Shorts chrome', () => {
77
+ it('renders its distinguishing chrome: channel row and Subscribe pill', () => {
78
+ installResizeObserver({ width: 1080, height: 1920 })
79
+ const { getByText, getAllByText } = render(<SocialSafeZoneOverlay platform="youtube" />)
80
+
81
+ expect(getByText('9:41')).toBeTruthy()
82
+ expect(getByText('@channel')).toBeTruthy()
83
+ expect(getByText('Subscribe')).toBeTruthy()
84
+ // Right rail — thumbs-up and comments are both counted; thumbs-down,
85
+ // share, remix and the sound thumbnail are icon-only (no count).
86
+ expect(getAllByText('2.8M')).toHaveLength(2)
87
+
88
+ // Bottom app nav
89
+ expect(getByText('Home')).toBeTruthy()
90
+ expect(getByText('Subscriptions')).toBeTruthy()
91
+ })
92
+ })
93
+
94
+ describe('SocialSafeZoneOverlay — Instagram Reels chrome', () => {
95
+ it('renders its distinguishing chrome: the Reels title and a Follow pill', () => {
96
+ installResizeObserver({ width: 1080, height: 1920 })
97
+ const { getByText, getAllByText } = render(<SocialSafeZoneOverlay platform="instagram" />)
98
+
99
+ expect(getByText('9:41')).toBeTruthy()
100
+ expect(getByText('Reels')).toBeTruthy()
101
+ expect(getByText('Follow')).toBeTruthy()
102
+ expect(getByText('Your name')).toBeTruthy()
103
+ // Right rail — likes, comments, reshare and send are each counted 2.8M.
104
+ expect(getAllByText('2.8M')).toHaveLength(4)
105
+ // Immersive reel view also draws the add-comment bar.
106
+ expect(getByText('Add comment...')).toBeTruthy()
107
+ })
108
+ })
109
+
110
+ describe('SocialSafeZoneOverlay — copy style', () => {
111
+ it('never uses an em dash, on any platform', () => {
112
+ installResizeObserver({ width: 1080, height: 1920 })
113
+ for (const platform of ['tiktok', 'youtube', 'instagram'] as const) {
114
+ const { getByTestId, unmount } = render(<SocialSafeZoneOverlay platform={platform} />)
115
+ expect(getByTestId('social-safe-zone-overlay').textContent).not.toContain('—')
116
+ unmount()
117
+ }
118
+ })
119
+ })
120
+
121
+ describe('SocialSafeZoneOverlay — never steals a click', () => {
122
+ it('the root layer is pointer-events-none, on every platform', () => {
123
+ installResizeObserver({ width: 1080, height: 1920 })
124
+ for (const platform of ['tiktok', 'youtube', 'instagram'] as const) {
125
+ const { getByTestId, unmount } = render(<SocialSafeZoneOverlay platform={platform} />)
126
+ const root = getByTestId('social-safe-zone-overlay')
127
+ expect(root.className).toContain('pointer-events-none')
128
+ unmount()
129
+ }
130
+ })
131
+ })
132
+
133
+ describe('SocialSafeZoneOverlay — scales with its container, not a fixed pixel size', () => {
134
+ it('a container matching the 1080×1920 design canvas scales 1:1', () => {
135
+ installResizeObserver({ width: 1080, height: 1920 })
136
+ const { getByTestId } = render(<SocialSafeZoneOverlay platform="tiktok" />)
137
+ expect(getByTestId('social-safe-zone-canvas').style.transform).toContain('scale(1)')
138
+ })
139
+
140
+ it('a half-size container halves the scale, proportionally, not to a fixed px value', () => {
141
+ const ro = installResizeObserver({ width: 1080, height: 1920 })
142
+ const { getByTestId } = render(<SocialSafeZoneOverlay platform="tiktok" />)
143
+ const canvas = getByTestId('social-safe-zone-canvas')
144
+ expect(canvas.style.transform).toContain('scale(1)')
145
+
146
+ act(() => { ro.fire(540, 960) })
147
+ expect(canvas.style.transform).toContain('scale(0.5)')
148
+ })
149
+
150
+ it('a landscape (non-vertical) container contain-fits instead of overflowing', () => {
151
+ // 16:9 landscape box: the design canvas is scaled by the SMALLER of the
152
+ // two axis ratios (height-bound here) so the whole 1080×1920 chrome
153
+ // stays inside the box on both axes rather than spilling past its
154
+ // narrower dimension — the "does not break or overflow" degrade for a
155
+ // non-vertical preview.
156
+ installResizeObserver({ width: 1920, height: 1080 })
157
+ const { getByTestId } = render(<SocialSafeZoneOverlay platform="tiktok" />)
158
+ const canvas = getByTestId('social-safe-zone-canvas')
159
+ const expectedScale = Math.min(1920 / 1080, 1080 / 1920)
160
+ expect(canvas.style.transform).toContain(`scale(${expectedScale})`)
161
+
162
+ const root = getByTestId('social-safe-zone-overlay')
163
+ expect(root.className).toContain('overflow-hidden')
164
+ })
165
+ })
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest'
1
+ import { describe, it, expect, afterEach, beforeEach } from 'vitest'
2
2
  import {
3
3
  CAPTION_DRAG_SLOP_PX,
4
4
  CAPTION_MAX_SCALE,
@@ -6,6 +6,7 @@ import {
6
6
  captionDragGeometry,
7
7
  captionDragPatch,
8
8
  hasEscapedClickSlop,
9
+ measureCaptionContentRect,
9
10
  readCaptionGeometry,
10
11
  screenDeltaToFramePercent,
11
12
  type CaptionDragState,
@@ -166,3 +167,121 @@ describe('captionDragPatch', () => {
166
167
  .toEqual({ scale: 2 })
167
168
  })
168
169
  })
170
+
171
+ // ── measureCaptionContentRect ───────────────────────────────────────────────
172
+ //
173
+ // Captions have lanes, so a template can paint several captions at once. The
174
+ // selection box must wrap ONE of them, which is what the `segmentId` argument
175
+ // is for. These cover the scoping and — just as important — the fallback that
176
+ // keeps a template with no `data-caption-id` behaving exactly as it did before
177
+ // lanes existed.
178
+
179
+ /** Client rect with the fields the measurer reads. jsdom does no layout. */
180
+ function fixedRect(left: number, top: number, right: number, bottom: number): DOMRect {
181
+ return {
182
+ left, top, right, bottom,
183
+ width: right - left, height: bottom - top,
184
+ x: left, y: top,
185
+ toJSON: () => ({}),
186
+ } as DOMRect
187
+ }
188
+
189
+ // Where each caption's text "paints", keyed by the text itself. Two boxes far
190
+ // apart on the frame, so a measurement that accidentally unions them is
191
+ // obvious rather than off by a few pixels.
192
+ const BOXES: Record<string, [left: number, top: number, right: number, bottom: number]> = {
193
+ bottom: [100, 1700, 300, 1750], // lane 0 — low on the frame
194
+ top: [400, 200, 900, 280], // lane 1 — high on the frame
195
+ }
196
+ const BOTTOM_RECT = { left: 100, top: 1700, width: 200, height: 50 }
197
+ const TOP_RECT = { left: 400, top: 200, width: 500, height: 80 }
198
+ // What an unscoped walk sees: the union of both, spanning most of the frame.
199
+ const UNION_RECT = { left: 100, top: 200, width: 800, height: 1550 }
200
+
201
+ beforeEach(() => {
202
+ // jsdom has no Range.prototype.getBoundingClientRect at all (and Element's
203
+ // returns zeros), so feed the walk a rect table keyed by the text node it is
204
+ // measuring. Assigned directly — vi.spyOn needs a pre-existing method.
205
+ Range.prototype.getBoundingClientRect = function (this: Range): DOMRect {
206
+ const text = this.startContainer.nodeValue?.trim() ?? ''
207
+ const box = BOXES[text]
208
+ return box ? fixedRect(box[0], box[1], box[2], box[3]) : fixedRect(0, 0, 0, 0)
209
+ }
210
+ })
211
+
212
+ afterEach(() => {
213
+ delete (Range.prototype as { getBoundingClientRect?: unknown }).getBoundingClientRect
214
+ })
215
+
216
+ function mount(html: string): HTMLElement {
217
+ const root = document.createElement('div')
218
+ root.innerHTML = html
219
+ return root
220
+ }
221
+
222
+ /** Two marked caption subtrees — what the templates render for two lanes. */
223
+ const TWO_LANES =
224
+ '<div data-caption-id="cap-bottom"><div><span>bottom</span></div></div>' +
225
+ '<div data-caption-id="cap-top"><div><span>top</span></div></div>'
226
+
227
+ /** One caption from a template that predates `data-caption-id`. */
228
+ const UNMARKED = '<div><div><span>bottom</span></div></div>'
229
+
230
+ describe('measureCaptionContentRect — scoping to one caption', () => {
231
+ it('measures only the requested subtree when two are marked', () => {
232
+ const root = mount(TWO_LANES)
233
+ expect(measureCaptionContentRect(root, 'cap-top')).toEqual(TOP_RECT)
234
+ expect(measureCaptionContentRect(root, 'cap-bottom')).toEqual(BOTTOM_RECT)
235
+ })
236
+
237
+ it('unions everything under the root when no segment is named', () => {
238
+ // The pre-lane behaviour, kept for callers that have no target in mind.
239
+ expect(measureCaptionContentRect(mount(TWO_LANES))).toEqual(UNION_RECT)
240
+ })
241
+
242
+ it('finds a marker nested below the root, not just a direct child', () => {
243
+ const root = mount(`<div class="wrapper">${TWO_LANES}</div>`)
244
+ expect(measureCaptionContentRect(root, 'cap-top')).toEqual(TOP_RECT)
245
+ })
246
+ })
247
+
248
+ describe('measureCaptionContentRect — fallback when the marker is absent', () => {
249
+ it('falls back to the whole root for a template that emits no marker', () => {
250
+ // Today's numbers, unchanged: a host-supplied or pre-lane template must
251
+ // keep its selection box rather than losing it.
252
+ expect(measureCaptionContentRect(mount(UNMARKED), 'cap-0')).toEqual(BOTTOM_RECT)
253
+ })
254
+
255
+ it('falls back to the whole root when the named segment is not the one on screen', () => {
256
+ expect(measureCaptionContentRect(mount(TWO_LANES), 'cap-nonexistent')).toEqual(UNION_RECT)
257
+ })
258
+
259
+ it('an id containing selector metacharacters cannot throw — it just misses and falls back', () => {
260
+ // project.json is hand-editable, so the lookup compares attributes rather
261
+ // than building a `[data-caption-id="…"]` selector that a quote would break.
262
+ expect(() => measureCaptionContentRect(mount(TWO_LANES), 'cap"]:not(*)')).not.toThrow()
263
+ expect(measureCaptionContentRect(mount(TWO_LANES), 'cap"]:not(*)')).toEqual(UNION_RECT)
264
+ })
265
+ })
266
+
267
+ describe('measureCaptionContentRect — nothing painted', () => {
268
+ it('returns null for an empty subtree, so the caller hides the box', () => {
269
+ expect(measureCaptionContentRect(mount(''))).toBeNull()
270
+ expect(measureCaptionContentRect(mount('<div data-caption-id="cap-0"></div>'), 'cap-0')).toBeNull()
271
+ })
272
+
273
+ it('ignores whitespace-only text nodes', () => {
274
+ expect(measureCaptionContentRect(mount('<div data-caption-id="cap-0"> </div>'), 'cap-0')).toBeNull()
275
+ })
276
+
277
+ it('returns null for the requested caption even while the OTHER one is painting', () => {
278
+ // `pop` renders no word between two word windows; the box must disappear
279
+ // for that caption rather than snapping onto its neighbour.
280
+ const root = mount(
281
+ '<div data-caption-id="cap-bottom"><div><span>bottom</span></div></div>' +
282
+ '<div data-caption-id="cap-top"></div>',
283
+ )
284
+ expect(measureCaptionContentRect(root, 'cap-top')).toBeNull()
285
+ expect(measureCaptionContentRect(root, 'cap-bottom')).toEqual(BOTTOM_RECT)
286
+ })
287
+ })