@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,451 @@
1
+ /**
2
+ * T1 — the clock → painter bridge subtracts the shared AudioContext's
3
+ * `outputLatency + baseLatency` on every emit, so the painted playhead
4
+ * matches what is actually AUDIBLE (not what the frames-consumed clock
5
+ * has counted). Both the engine bridge (`useEnginePlayback.emitTime`)
6
+ * and the `<video>` fallback bridge (`useVideoPlayback`'s playback-advance
7
+ * callsites) go through this compensation.
8
+ *
9
+ * The fake ctx (`stubCtx`) is written straight into `window.__montajSharedCtx`
10
+ * because `getSharedAudioContext()` only rebuilds when the cached entry is
11
+ * missing or closed — a running stub is used exactly like a real one.
12
+ */
13
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
14
+ import { renderHook, act } from '@testing-library/react'
15
+ import type { EditorProject, VisualItem, VisualTrack } from '../../../schema'
16
+ import type { EngineStats, EngineStatus } from '../../../engine'
17
+
18
+ // jsdom implements neither `play()` nor `pause()`. Installed once, at module
19
+ // scope, rather than as a per-test spy: Testing Library's own `afterEach`
20
+ // cleanup unmounts the hook — and therefore runs the audio-lane teardown,
21
+ // which pauses every element — AFTER a per-test `restoreAllMocks()` would
22
+ // have put the unimplemented originals back.
23
+ Object.defineProperty(HTMLMediaElement.prototype, 'paused', {
24
+ configurable: true,
25
+ get(this: HTMLMediaElement & { __paused?: boolean }) { return this.__paused !== false },
26
+ })
27
+ HTMLMediaElement.prototype.play = function (this: HTMLMediaElement & { __paused?: boolean }) {
28
+ this.__paused = false
29
+ return Promise.resolve()
30
+ }
31
+ HTMLMediaElement.prototype.pause = function (this: HTMLMediaElement & { __paused?: boolean }) {
32
+ this.__paused = true
33
+ }
34
+
35
+ // ── Shared: a controllable stub AudioContext ─────────────────────────────────
36
+
37
+ interface StubCtx {
38
+ state: 'running' | 'suspended'
39
+ outputLatency: number
40
+ baseLatency: number
41
+ resume: () => Promise<void>
42
+ destination: object
43
+ createMediaElementSource: () => { connect: () => void }
44
+ createGain: () => { gain: { value: number }; connect: () => void }
45
+ }
46
+
47
+ function stubCtx(over: Partial<Pick<StubCtx, 'outputLatency' | 'baseLatency' | 'state'>> = {}): StubCtx {
48
+ const ctx: StubCtx = {
49
+ state: over.state ?? 'running',
50
+ outputLatency: over.outputLatency ?? 0,
51
+ baseLatency: over.baseLatency ?? 0,
52
+ resume: () => Promise.resolve(),
53
+ destination: {},
54
+ createMediaElementSource: () => ({ connect: () => {} }),
55
+ createGain: () => ({ gain: { value: 1 }, connect: () => {} }),
56
+ }
57
+ ;(window as unknown as { __montajSharedCtx?: StubCtx }).__montajSharedCtx = ctx
58
+ return ctx
59
+ }
60
+
61
+ function clearCtx() {
62
+ delete (window as unknown as { __montajSharedCtx?: unknown }).__montajSharedCtx
63
+ }
64
+
65
+ // jsdom has no `AudioContext` at all — a default is needed so anything that
66
+ // hits `new AudioContext()` on a cold path doesn't ReferenceError. The tests
67
+ // below always seed `__montajSharedCtx` directly, so this default is only a
68
+ // safety net.
69
+ ;(window as unknown as { AudioContext: unknown }).AudioContext = class {
70
+ state = 'running'
71
+ outputLatency = 0
72
+ baseLatency = 0
73
+ destination = {}
74
+ resume() { return Promise.resolve() }
75
+ createMediaElementSource() { return { connect() {} } }
76
+ createGain() { return { gain: { value: 1 }, connect() {} } }
77
+ }
78
+
79
+ // ── latencySeconds() — the pure helper ───────────────────────────────────────
80
+
81
+ import { latencySeconds } from '../audio-context'
82
+
83
+ describe('latencySeconds', () => {
84
+ it('sums outputLatency and baseLatency', () => {
85
+ expect(latencySeconds({ outputLatency: 0.03, baseLatency: 0.005 })).toBeCloseTo(0.035, 10)
86
+ })
87
+
88
+ it('returns 0.03 for the plan spec case (outputLatency=0.03, baseLatency=0)', () => {
89
+ expect(latencySeconds({ outputLatency: 0.03, baseLatency: 0 })).toBeCloseTo(0.03, 10)
90
+ })
91
+
92
+ it('clamps a negative sum to zero', () => {
93
+ // Not observed in the wild, but a malformed stub or a broken driver could
94
+ // report a negative — the bridge must never emit a time greater than the
95
+ // clock's own now(), which is what a negative would produce.
96
+ expect(latencySeconds({ outputLatency: -0.5, baseLatency: 0 })).toBe(0)
97
+ })
98
+
99
+ it('tolerates missing / NaN latency fields (non-Chromium contexts)', () => {
100
+ expect(latencySeconds({ outputLatency: NaN, baseLatency: 0.005 })).toBeCloseTo(0.005, 10)
101
+ expect(latencySeconds({ outputLatency: 0.03, baseLatency: NaN })).toBeCloseTo(0.03, 10)
102
+ expect(latencySeconds({ outputLatency: NaN, baseLatency: NaN })).toBe(0)
103
+ })
104
+ })
105
+
106
+ // ── The <video> fallback bridge — useVideoPlayback ───────────────────────────
107
+
108
+ import { useVideoPlayback } from '../useVideoPlayback'
109
+
110
+ function videoClip(over: Partial<VisualItem> = {}): VisualItem {
111
+ return { id: 'a', type: 'video', src: '/a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, ...over } as VisualItem
112
+ }
113
+
114
+ function videoProject(items: VisualItem[], track: Partial<VisualTrack> = {}): EditorProject {
115
+ return {
116
+ id: 'lat-video',
117
+ status: 'draft',
118
+ settings: { resolution: [1080, 1920] },
119
+ tracks: [{ id: 'trk-0', items, ...track }],
120
+ } as EditorProject
121
+ }
122
+
123
+ /** A `<video>` element whose `currentTime` is a real read/write property. */
124
+ function fakeVideo(): HTMLVideoElement {
125
+ const el = document.createElement('video')
126
+ let t = 0
127
+ Object.defineProperty(el, 'currentTime', {
128
+ get: () => t,
129
+ set: (v: number) => { t = v },
130
+ configurable: true,
131
+ })
132
+ return el
133
+ }
134
+
135
+ function mountVideo(project: EditorProject, onTimeUpdate: (t: number) => void) {
136
+ const harness = renderHook(
137
+ ({ p }: { p: EditorProject }) => useVideoPlayback(p, 0, onTimeUpdate, (path) => path),
138
+ { initialProps: { p: project } },
139
+ )
140
+ const v0 = fakeVideo()
141
+ const v1 = fakeVideo()
142
+ harness.result.current.video0Ref.current = v0
143
+ harness.result.current.video1Ref.current = v1
144
+ harness.rerender({ p: { ...project } })
145
+ return { ...harness, v0, v1 }
146
+ }
147
+
148
+ describe('<video> fallback bridge — useVideoPlayback', () => {
149
+ let playSpy: ReturnType<typeof vi.spyOn>
150
+ let pauseSpy: ReturnType<typeof vi.spyOn>
151
+ beforeEach(() => {
152
+ playSpy = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
153
+ pauseSpy = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
154
+ })
155
+ afterEach(() => {
156
+ playSpy.mockRestore()
157
+ pauseSpy.mockRestore()
158
+ vi.restoreAllMocks()
159
+ clearCtx()
160
+ })
161
+
162
+ it('emits now() - outputLatency on handleTimeUpdate (spec: 0.03 → t-0.03)', () => {
163
+ stubCtx({ outputLatency: 0.03, baseLatency: 0 })
164
+ const onTimeUpdate = vi.fn<(t: number) => void>()
165
+ const { result, v0 } = mountVideo(videoProject([videoClip()]), onTimeUpdate)
166
+ onTimeUpdate.mockClear()
167
+
168
+ v0.currentTime = 2 // → project time = clip.start + (2 - inPoint) = 2
169
+ act(() => { result.current.handleTimeUpdate() })
170
+
171
+ // The last emit is the main handleTimeUpdate emit at the tail of the
172
+ // handler; earlier writes on the pre-cut path fold into it.
173
+ const emitted = onTimeUpdate.mock.calls[onTimeUpdate.mock.calls.length - 1]?.[0]
174
+ expect(emitted).toBeCloseTo(2 - 0.03, 6)
175
+ })
176
+
177
+ it('re-reads latency LIVE across ticks (device switch)', () => {
178
+ const ctx = stubCtx({ outputLatency: 0.03, baseLatency: 0 })
179
+ const onTimeUpdate = vi.fn<(t: number) => void>()
180
+ const { result, v0 } = mountVideo(videoProject([videoClip()]), onTimeUpdate)
181
+
182
+ v0.currentTime = 1
183
+ onTimeUpdate.mockClear()
184
+ act(() => { result.current.handleTimeUpdate() })
185
+ expect(onTimeUpdate.mock.calls[onTimeUpdate.mock.calls.length - 1]?.[0]).toBeCloseTo(1 - 0.03, 6)
186
+
187
+ // Device switch mid-session — a bluetooth output arrives with a higher
188
+ // buffer. Same ctx object, new field value: the bridge picks it up on the
189
+ // very next tick, no re-mount required.
190
+ ctx.outputLatency = 0.2
191
+ v0.currentTime = 2
192
+ onTimeUpdate.mockClear()
193
+ act(() => { result.current.handleTimeUpdate() })
194
+ expect(onTimeUpdate.mock.calls[onTimeUpdate.mock.calls.length - 1]?.[0]).toBeCloseTo(2 - 0.2, 6)
195
+ })
196
+
197
+ it('clamps the emitted time at zero when latency exceeds the current time', () => {
198
+ stubCtx({ outputLatency: 0.5, baseLatency: 0 })
199
+ const onTimeUpdate = vi.fn<(t: number) => void>()
200
+ const { result, v0 } = mountVideo(videoProject([videoClip()]), onTimeUpdate)
201
+
202
+ v0.currentTime = 0.1 // project time ≈ 0.1, latency 0.5 → would go negative
203
+ onTimeUpdate.mockClear()
204
+ act(() => { result.current.handleTimeUpdate() })
205
+ expect(onTimeUpdate.mock.calls[onTimeUpdate.mock.calls.length - 1]?.[0]).toBe(0)
206
+ })
207
+ })
208
+
209
+ // ── The engine bridge — useEnginePlayback ────────────────────────────────────
210
+
211
+ // The engine module owns three WebCodecs paths jsdom cannot execute; the fake
212
+ // used here reproduces the seam this hook actually observes (emit, transport
213
+ // transitions, seek/pause/play counters) — the same pattern as the neighboring
214
+ // `useEnginePlayback.test.tsx`.
215
+ interface FakeEngine {
216
+ deps: {
217
+ onTime?: (t: number) => void
218
+ onStatusChange?: (s: EngineStatus) => void
219
+ onError?: (m: string) => void
220
+ startProjectS?: number
221
+ fileUrl: (p: string) => string
222
+ }
223
+ transport: EngineStatus['transport']
224
+ now: number
225
+ seeks: number[]
226
+ emit(t: number): void
227
+ setTransport(t: EngineStatus['transport']): void
228
+ }
229
+
230
+ const engines = vi.hoisted(() => [] as unknown[])
231
+
232
+ vi.mock('../../../engine', async (importOriginal) => {
233
+ const actual = await importOriginal<typeof import('../../../engine')>()
234
+ return {
235
+ ...actual,
236
+ createEngine: (project: EditorProject, deps: FakeEngine['deps']) => {
237
+ const status = (): EngineStatus => ({
238
+ transport: engine.transport,
239
+ picture: 'video',
240
+ clipId: null,
241
+ seeking: false,
242
+ clock: 'fallback',
243
+ })
244
+ const engine: FakeEngine & {
245
+ attach: (c: HTMLCanvasElement | null) => void
246
+ play: () => void
247
+ pause: () => void
248
+ seek: (t: number) => void
249
+ setRate: (r: number) => void
250
+ updateProject: (p: EditorProject) => void
251
+ status: () => EngineStatus
252
+ clock: { now: () => number; playing: boolean; kind: 'audio' | 'fallback' }
253
+ stats: () => EngineStats
254
+ dispose: () => void
255
+ } = {
256
+ deps,
257
+ transport: 'paused',
258
+ now: deps.startProjectS ?? 0,
259
+ seeks: [],
260
+ emit(t: number) { engine.now = t; deps.onTime?.(t) },
261
+ setTransport(t) { engine.transport = t; deps.onStatusChange?.(status()) },
262
+ attach: () => {},
263
+ play: () => { engine.transport = 'playing'; deps.onStatusChange?.(status()) },
264
+ pause: () => { engine.transport = 'paused'; deps.onStatusChange?.(status()) },
265
+ seek: (t: number) => { engine.seeks.push(t); engine.now = t },
266
+ setRate: () => {},
267
+ updateProject: () => {},
268
+ status,
269
+ clock: {
270
+ get now() { return () => engine.now },
271
+ get playing() { return engine.transport === 'playing' },
272
+ kind: 'fallback' as const,
273
+ } as unknown as { now: () => number; playing: boolean; kind: 'audio' | 'fallback' },
274
+ stats: () => ({ fps: 24, dropped: 0, buffered: 0, clock: 'fallback' }),
275
+ dispose: () => {},
276
+ }
277
+ engines.push(engine)
278
+ return engine
279
+ },
280
+ }
281
+ })
282
+
283
+ // Import AFTER the mock declaration for readability — vi.mock is hoisted.
284
+ import { useEnginePlayback } from '../useEnginePlayback'
285
+
286
+ function engineProject(): EditorProject {
287
+ return {
288
+ id: 'lat-engine',
289
+ status: 'draft',
290
+ settings: { resolution: [1080, 1920], fps: 30 },
291
+ tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', proxySrc: 'a_proxy.mp4', start: 0, end: 10 }] }],
292
+ } as EditorProject
293
+ }
294
+
295
+ function setupEngine() {
296
+ const onTimeUpdate = vi.fn<(t: number) => void>()
297
+ const view = renderHook(
298
+ ({ t }: { t: number }) => useEnginePlayback(engineProject(), t, onTimeUpdate, (p) => p),
299
+ { initialProps: { t: 0 } },
300
+ )
301
+ const engine = engines[engines.length - 1] as FakeEngine
302
+ return { view, engine, onTimeUpdate }
303
+ }
304
+
305
+ describe('engine bridge — useEnginePlayback.emitTime', () => {
306
+ beforeEach(() => { engines.length = 0 })
307
+ afterEach(() => { clearCtx() })
308
+
309
+ it('emits now() - outputLatency during playback (spec: 0.03 → t-0.03)', () => {
310
+ stubCtx({ outputLatency: 0.03, baseLatency: 0 })
311
+ const { engine, onTimeUpdate } = setupEngine()
312
+
313
+ act(() => { engine.setTransport('playing') })
314
+ onTimeUpdate.mockClear()
315
+ act(() => { engine.emit(1.5) })
316
+
317
+ expect(onTimeUpdate).toHaveBeenCalledWith(expect.closeTo(1.5 - 0.03, 6))
318
+ })
319
+
320
+ it('sums outputLatency and baseLatency during playback', () => {
321
+ stubCtx({ outputLatency: 0.03, baseLatency: 0.005 })
322
+ const { engine, onTimeUpdate } = setupEngine()
323
+
324
+ act(() => { engine.setTransport('playing') })
325
+ onTimeUpdate.mockClear()
326
+ act(() => { engine.emit(1.5) })
327
+
328
+ expect(onTimeUpdate).toHaveBeenCalledWith(expect.closeTo(1.5 - 0.035, 6))
329
+ })
330
+
331
+ it('re-reads latency LIVE across ticks (device switch)', () => {
332
+ const ctx = stubCtx({ outputLatency: 0.03, baseLatency: 0 })
333
+ const { engine, onTimeUpdate } = setupEngine()
334
+
335
+ act(() => { engine.setTransport('playing') })
336
+ onTimeUpdate.mockClear()
337
+ act(() => { engine.emit(1.0) })
338
+ expect(onTimeUpdate).toHaveBeenLastCalledWith(expect.closeTo(1.0 - 0.03, 6))
339
+
340
+ ctx.outputLatency = 0.2
341
+ act(() => { engine.emit(2.0) })
342
+ expect(onTimeUpdate).toHaveBeenLastCalledWith(expect.closeTo(2.0 - 0.2, 6))
343
+ })
344
+
345
+ it('does NOT compensate when the transport is paused (no seek/scrub-display regression)', () => {
346
+ // The paused path emits via `scheduler.apply` on seek-land with the exact
347
+ // seek target. Compensating that would show the user 4.97 after they
348
+ // scrubbed to 5.00 — precisely the "seek/scrub display regression" the
349
+ // task tells us to avoid.
350
+ stubCtx({ outputLatency: 0.03, baseLatency: 0 })
351
+ const { engine, onTimeUpdate } = setupEngine()
352
+ // Transport stays 'paused' (its default).
353
+ onTimeUpdate.mockClear()
354
+
355
+ act(() => { engine.emit(5.0) })
356
+ expect(onTimeUpdate).toHaveBeenLastCalledWith(5.0)
357
+ })
358
+
359
+ it('clamps the emitted time at zero when latency exceeds the current time', () => {
360
+ stubCtx({ outputLatency: 0.5, baseLatency: 0 })
361
+ const { engine, onTimeUpdate } = setupEngine()
362
+
363
+ act(() => { engine.setTransport('playing') })
364
+ onTimeUpdate.mockClear()
365
+ act(() => { engine.emit(0.1) })
366
+
367
+ expect(onTimeUpdate).toHaveBeenLastCalledWith(0)
368
+ })
369
+ })
370
+
371
+ // ── syncAudioTracks callsites OUTSIDE emitTime get RAW time, not painted ─────
372
+ //
373
+ // `emitTime`'s own direct `syncAudioTracks(t, playing)` call was never broken
374
+ // — it already hands the raw `t` parameter through. The regression these
375
+ // guard is the OTHER two callsites (the "audio lane added mid-session" effect
376
+ // and the "transport transition" effect), which used to reuse
377
+ // `lastEmittedRef.current` — the PAINTED, latency-compensated mirror — and
378
+ // would double-lag a newly added lane, or a play/pause re-sync, by
379
+ // `latencySeconds`.
380
+
381
+ describe('engine bridge — syncAudioTracks callsites outside emitTime', () => {
382
+ let RealAudio: typeof Audio
383
+ const audioEls: HTMLAudioElement[] = []
384
+
385
+ beforeEach(() => {
386
+ engines.length = 0
387
+ audioEls.length = 0
388
+ RealAudio = window.Audio
389
+ ;(window as unknown as { Audio: unknown }).Audio = class extends RealAudio {
390
+ constructor(src?: string) { super(src); audioEls.push(this as unknown as HTMLAudioElement) }
391
+ }
392
+ })
393
+ afterEach(() => {
394
+ ;(window as unknown as { Audio: unknown }).Audio = RealAudio
395
+ clearCtx()
396
+ })
397
+
398
+ function projectWithAudio(includeAudio: boolean): EditorProject {
399
+ return {
400
+ ...engineProject(),
401
+ audio: {
402
+ tracks: includeAudio
403
+ ? [{ id: 'a0', src: 'music.mp3', start: 0, end: 100, inPoint: 0, volume: 1 } as never]
404
+ : [],
405
+ },
406
+ } as EditorProject
407
+ }
408
+
409
+ it('a lane added mid-session is placed at the RAW playhead, not the latency-painted one', () => {
410
+ stubCtx({ outputLatency: 1, baseLatency: 0 })
411
+ const onTimeUpdate = vi.fn<(t: number) => void>()
412
+ const view = renderHook(
413
+ ({ p }: { p: EditorProject }) => useEnginePlayback(p, 0, onTimeUpdate, (path) => path),
414
+ { initialProps: { p: projectWithAudio(false) } },
415
+ )
416
+ const engine = engines[engines.length - 1] as FakeEngine
417
+
418
+ act(() => { engine.setTransport('playing') })
419
+ act(() => { engine.emit(10) }) // raw=10, painted=9 (1s latency) — mirrors diverge here
420
+
421
+ // Add the audio track mid-session, WITHOUT another emit: the effect must
422
+ // read the mirrored RAW time, not the painted one.
423
+ act(() => { view.rerender({ p: projectWithAudio(true) }) })
424
+
425
+ expect(audioEls).toHaveLength(1)
426
+ // trackTime = playhead - start(0) + inPoint(0): 10 if fixed, 9 if the
427
+ // painted mirror regresses back in.
428
+ expect(audioEls[0].currentTime).toBeCloseTo(10, 6)
429
+ })
430
+
431
+ it('a transport transition re-syncs lanes to the RAW playhead, not the latency-painted one', () => {
432
+ stubCtx({ outputLatency: 1, baseLatency: 0 })
433
+ const onTimeUpdate = vi.fn<(t: number) => void>()
434
+ renderHook(
435
+ ({ p }: { p: EditorProject }) => useEnginePlayback(p, 0, onTimeUpdate, (path) => path),
436
+ { initialProps: { p: projectWithAudio(true) } },
437
+ )
438
+ const engine = engines[engines.length - 1] as FakeEngine
439
+
440
+ act(() => { engine.setTransport('playing') })
441
+ act(() => { engine.emit(10) }) // raw=10, painted=9; the direct emitTime call already seeks correctly
442
+
443
+ // Knock the element off both candidate targets so the NEXT resync — from
444
+ // the transport-transition effect alone, no intervening emit — is the
445
+ // only thing that can put it back.
446
+ audioEls[0].currentTime = 50
447
+ act(() => { engine.setTransport('paused') })
448
+
449
+ expect(audioEls[0].currentTime).toBeCloseTo(10, 6)
450
+ })
451
+ })
@@ -0,0 +1,75 @@
1
+ /** SP3 fix B2 — capability gate + failure suppression. */
2
+ import { describe, it, expect, afterEach } from 'vitest'
3
+ import {
4
+ gateProxy,
5
+ isProxyUsable,
6
+ markProxyFailed,
7
+ proxyPlaybackSupported,
8
+ __clearFailedProxiesForTests,
9
+ __setProxySupportForTests,
10
+ } from '../proxySupport'
11
+
12
+ afterEach(() => {
13
+ __setProxySupportForTests(null)
14
+ __clearFailedProxiesForTests()
15
+ })
16
+
17
+ describe('proxyPlaybackSupported', () => {
18
+ it('reports supported under the test-setup canPlayType stub', () => {
19
+ __setProxySupportForTests(null)
20
+ expect(proxyPlaybackSupported()).toBe(true)
21
+ })
22
+
23
+ it('probes for the codec the proxies are actually encoded in', () => {
24
+ // PROXY_MIME is module-private, so pin it where it is observable: the
25
+ // string handed to the capability probe. Without this, nothing in the
26
+ // suite ties the gate to the encoder — `lib/proxy.py` could move to a new
27
+ // codec and every test here would still pass while the editor silently
28
+ // judged real proxies undecodable (or worse, decodable on stale evidence).
29
+ __setProxySupportForTests(null)
30
+ const seen: string[] = []
31
+ const ms = (globalThis as { MediaSource?: { isTypeSupported?: (t: string) => boolean } }).MediaSource
32
+ const realIsTypeSupported = ms?.isTypeSupported
33
+ const realCanPlayType = HTMLMediaElement.prototype.canPlayType
34
+ if (ms) ms.isTypeSupported = (t: string) => { seen.push(t); return true }
35
+ HTMLMediaElement.prototype.canPlayType = function (t: string) { seen.push(t); return 'probably' }
36
+ try {
37
+ proxyPlaybackSupported()
38
+ } finally {
39
+ if (ms) ms.isTypeSupported = realIsTypeSupported
40
+ HTMLMediaElement.prototype.canPlayType = realCanPlayType
41
+ }
42
+ expect(seen).toHaveLength(1)
43
+ expect(seen[0]).toBe('video/mp4; codecs="avc1.640028, opus"')
44
+ })
45
+ })
46
+
47
+ describe('gateProxy', () => {
48
+ const item = { id: 'c1', src: '/a/orig.mov', proxySrc: '/a/orig_proxy_hable1.mp4' }
49
+
50
+ it('passes the item through untouched when the proxy is usable', () => {
51
+ __setProxySupportForTests(true)
52
+ expect(gateProxy(item)).toBe(item) // same reference — no needless copies
53
+ })
54
+
55
+ it('strips proxySrc when playback is unsupported, without mutating the input', () => {
56
+ __setProxySupportForTests(false)
57
+ const gated = gateProxy(item)
58
+ expect(gated.proxySrc).toBeUndefined()
59
+ expect(gated.src).toBe('/a/orig.mov')
60
+ expect(item.proxySrc).toBe('/a/orig_proxy_hable1.mp4') // input untouched
61
+ })
62
+
63
+ it('strips proxySrc after the proxy is marked failed, even when supported', () => {
64
+ __setProxySupportForTests(true)
65
+ markProxyFailed(item.proxySrc)
66
+ expect(isProxyUsable(item.proxySrc)).toBe(false)
67
+ expect(gateProxy(item).proxySrc).toBeUndefined()
68
+ })
69
+
70
+ it('leaves proxy-less items alone regardless of support', () => {
71
+ __setProxySupportForTests(false)
72
+ const plain: { id: string; src: string; proxySrc?: string } = { id: 'c2', src: '/b/orig.mov' }
73
+ expect(gateProxy(plain)).toBe(plain)
74
+ })
75
+ })
@@ -5,13 +5,34 @@ describe('videoTransformContainerStyle', () => {
5
5
  it('identity transform → empty style (no-op)', () => {
6
6
  expect(videoTransformContainerStyle({})).toEqual({})
7
7
  expect(videoTransformContainerStyle({ scale: 1, offsetX: 0, offsetY: 0 })).toEqual({})
8
+ // Identity spelled per-axis is still identity.
9
+ expect(videoTransformContainerStyle({ scaleX: 1, scaleY: 1, offsetX: 0, offsetY: 0 })).toEqual({})
10
+ })
11
+
12
+ it('a single non-identity axis defeats the no-op early return', () => {
13
+ expect(videoTransformContainerStyle({ scaleX: 2 }).transform).toBe('translate(0%, 0%) scale(2, 1)')
14
+ expect(videoTransformContainerStyle({ scaleY: 2 }).transform).toBe('translate(0%, 0%) scale(1, 2)')
8
15
  })
9
16
 
10
17
  it('scale + offset → translate then scale, origin center', () => {
11
18
  const s = videoTransformContainerStyle({ scale: 2, offsetX: 10, offsetY: -5 })
12
- expect(s.transform).toBe('translate(10%, -5%) scale(2)')
19
+ // Uniform `scale` fills BOTH arguments — same box the one-argument form drew.
20
+ expect(s.transform).toBe('translate(10%, -5%) scale(2, 2)')
21
+ expect(s.transformOrigin).toBe('center center')
22
+ })
23
+
24
+ it('per-axis scale emits each axis independently', () => {
25
+ const s = videoTransformContainerStyle({ scaleX: 1.5, scaleY: 0.5, offsetX: 10, offsetY: -5 })
26
+ expect(s.transform).toBe('translate(10%, -5%) scale(1.5, 0.5)')
13
27
  expect(s.transformOrigin).toBe('center center')
14
28
  })
29
+
30
+ it('per-axis wins over the uniform `scale`, one axis at a time', () => {
31
+ expect(videoTransformContainerStyle({ scale: 3, scaleY: 0.25 }).transform)
32
+ .toBe('translate(0%, 0%) scale(3, 0.25)')
33
+ expect(videoTransformContainerStyle({ scale: 3, scaleX: 0.25 }).transform)
34
+ .toBe('translate(0%, 0%) scale(0.25, 3)')
35
+ })
15
36
  })
16
37
 
17
38
  describe('videoTransformBoxPct', () => {
@@ -31,4 +52,11 @@ describe('videoTransformBoxPct', () => {
31
52
  expect(videoTransformBoxPct({ scale: 2, offsetX: 10, offsetY: -5 }))
32
53
  .toEqual({ width: 200, height: 200, left: -40, top: -55 })
33
54
  })
55
+
56
+ // Inherited for free: geometryFor resolves scaleX/scaleY, toCssBoxPct takes
57
+ // width/left from the X scale and height/top from the Y scale.
58
+ it('per-axis scale sizes width and height independently', () => {
59
+ expect(videoTransformBoxPct({ scaleX: 2, scaleY: 0.5 }))
60
+ .toEqual({ width: 200, height: 50, left: -50, top: 25 })
61
+ })
34
62
  })