@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,643 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { describe, it, expect } from 'vitest'
3
+ import { sampleTrack } from '@bycrux/timeline-core'
4
+ import type { VisualItem } from '../../schema'
5
+ import {
6
+ trackFor,
7
+ hasKeyframes,
8
+ isKeyframed,
9
+ canKeyframe,
10
+ valueAt,
11
+ setKeyframe,
12
+ removeKeyframe,
13
+ moveKeyframe,
14
+ setKeyframeEasing,
15
+ enableKeyframing,
16
+ disableKeyframing,
17
+ removeKeyframesAt,
18
+ isUniformScale,
19
+ transformProps,
20
+ } from '../keyframeOps'
21
+
22
+ function overlay(over: Partial<VisualItem> = {}): VisualItem {
23
+ return {
24
+ id: 'o1',
25
+ type: 'overlay',
26
+ src: 'o.jsx',
27
+ start: 5, // deliberately non-zero: `t` must stay item-relative, never absolute
28
+ end: 15,
29
+ props: {},
30
+ ...over,
31
+ }
32
+ }
33
+
34
+ describe('trackFor', () => {
35
+ it('returns undefined for an item with no keyframes at all', () => {
36
+ expect(trackFor(overlay(), 'scale')).toBeUndefined()
37
+ })
38
+
39
+ it('returns undefined when other props are keyframed but not this one', () => {
40
+ const item = overlay({ keyframes: [{ prop: 'opacity', points: [{ t: 0, value: 1 }] }] })
41
+ expect(trackFor(item, 'scale')).toBeUndefined()
42
+ })
43
+
44
+ it('returns the matching track', () => {
45
+ const track = { prop: 'scale' as const, points: [{ t: 0, value: 2 }] }
46
+ const item = overlay({ keyframes: [track] })
47
+ expect(trackFor(item, 'scale')).toBe(track)
48
+ })
49
+ })
50
+
51
+ describe('hasKeyframes', () => {
52
+ it('is false for no track', () => {
53
+ expect(hasKeyframes(overlay(), 'scale')).toBe(false)
54
+ })
55
+
56
+ it('is false for an empty-points track (defensive, not just "no track")', () => {
57
+ const item = overlay({ keyframes: [{ prop: 'scale', points: [] }] })
58
+ expect(hasKeyframes(item, 'scale')).toBe(false)
59
+ })
60
+
61
+ it('is true for a track with at least one point', () => {
62
+ const item = overlay({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 2 }] }] })
63
+ expect(hasKeyframes(item, 'scale')).toBe(true)
64
+ })
65
+ })
66
+
67
+ describe('isKeyframed', () => {
68
+ it('is false for an item with no keyframes field', () => {
69
+ expect(isKeyframed(overlay())).toBe(false)
70
+ })
71
+
72
+ it('is false when every track present is empty', () => {
73
+ expect(isKeyframed(overlay({ keyframes: [{ prop: 'scale', points: [] }] }))).toBe(false)
74
+ })
75
+
76
+ it('is true when any track has a point', () => {
77
+ const item = overlay({
78
+ keyframes: [
79
+ { prop: 'scale', points: [] },
80
+ { prop: 'opacity', points: [{ t: 0, value: 1 }] },
81
+ ],
82
+ })
83
+ expect(isKeyframed(item)).toBe(true)
84
+ })
85
+ })
86
+
87
+ describe('valueAt', () => {
88
+ it('returns the correct defaults when the item has neither keyframes nor static scalars', () => {
89
+ const item = overlay()
90
+ expect(valueAt(item, 'scale', 0)).toBe(1)
91
+ expect(valueAt(item, 'opacity', 0)).toBe(1)
92
+ expect(valueAt(item, 'offsetX', 0)).toBe(0)
93
+ expect(valueAt(item, 'offsetY', 0)).toBe(0)
94
+ expect(valueAt(item, 'rotation', 0)).toBe(0)
95
+ })
96
+
97
+ it('falls back to the static scalar when the prop is not keyframed', () => {
98
+ const item = overlay({ scale: 1.5, offsetX: 12, rotation: 90 })
99
+ expect(valueAt(item, 'scale', 3)).toBe(1.5)
100
+ expect(valueAt(item, 'offsetX', 3)).toBe(12)
101
+ expect(valueAt(item, 'rotation', 3)).toBe(90)
102
+ })
103
+
104
+ it('samples the curve when the prop is keyframed, ignoring the static scalar', () => {
105
+ const item = overlay({
106
+ offsetX: 999, // must be ignored — the track wins once it exists
107
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
108
+ })
109
+ expect(valueAt(item, 'offsetX', 0)).toBe(0)
110
+ expect(valueAt(item, 'offsetX', 10)).toBe(100)
111
+ expect(valueAt(item, 'offsetX', 5)).toBe(50) // linear midpoint
112
+ })
113
+ })
114
+
115
+ describe('setKeyframe', () => {
116
+ it('creates a track on a prop with none yet', () => {
117
+ const item = overlay()
118
+ const next = setKeyframe(item, 'opacity', 2, 0.5)
119
+ expect(trackFor(next, 'opacity')).toEqual({ prop: 'opacity', points: [{ t: 2, value: 0.5 }] })
120
+ })
121
+
122
+ it('adds a keyframe to an existing track', () => {
123
+ const item = overlay({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }] }] })
124
+ const next = setKeyframe(item, 'scale', 5, 2)
125
+ expect(trackFor(next, 'scale')!.points).toEqual([{ t: 0, value: 1 }, { t: 5, value: 2 }])
126
+ })
127
+
128
+ it('does not mutate the input item, its keyframes array, or its point objects', () => {
129
+ const originalPoints = [{ t: 0, value: 1 }]
130
+ const track = { prop: 'scale' as const, points: originalPoints }
131
+ const item = overlay({ keyframes: [track] })
132
+ const snapshot = JSON.parse(JSON.stringify(item))
133
+
134
+ const next = setKeyframe(item, 'scale', 5, 2)
135
+
136
+ expect(item).toEqual(snapshot)
137
+ expect(item.keyframes![0]).toBe(track)
138
+ expect(item.keyframes![0].points).toBe(originalPoints)
139
+ expect(originalPoints).toEqual([{ t: 0, value: 1 }])
140
+ expect(next).not.toBe(item)
141
+ expect(next.keyframes).not.toBe(item.keyframes)
142
+ })
143
+
144
+ it('out-of-order t values come back ascending (the normalize invariant)', () => {
145
+ let item = overlay()
146
+ item = setKeyframe(item, 'scale', 10, 2)
147
+ item = setKeyframe(item, 'scale', 0, 1)
148
+ item = setKeyframe(item, 'scale', 5, 1.5)
149
+ expect(trackFor(item, 'scale')!.points.map(p => p.t)).toEqual([0, 5, 10])
150
+ })
151
+
152
+ it('a duplicate t collapses to one point, the new value winning', () => {
153
+ let item = overlay()
154
+ item = setKeyframe(item, 'scale', 5, 1)
155
+ item = setKeyframe(item, 'scale', 5, 9)
156
+ const points = trackFor(item, 'scale')!.points
157
+ expect(points).toHaveLength(1)
158
+ expect(points[0]).toEqual({ t: 5, value: 9 })
159
+ })
160
+
161
+ it('replacing an existing point preserves its easing when none is passed', () => {
162
+ let item = overlay()
163
+ item = setKeyframe(item, 'scale', 5, 1, 'ease-in')
164
+ item = setKeyframe(item, 'scale', 5, 2) // no easing arg
165
+ expect(trackFor(item, 'scale')!.points[0]).toEqual({ t: 5, value: 2, easing: 'ease-in' })
166
+ })
167
+
168
+ it('a new easing argument overrides the preserved one', () => {
169
+ let item = overlay()
170
+ item = setKeyframe(item, 'scale', 5, 1, 'ease-in')
171
+ item = setKeyframe(item, 'scale', 5, 2, 'hold')
172
+ expect(trackFor(item, 'scale')!.points[0]).toEqual({ t: 5, value: 2, easing: 'hold' })
173
+ })
174
+
175
+ it('ignores a non-finite t and returns the item unchanged', () => {
176
+ const item = overlay()
177
+ expect(setKeyframe(item, 'scale', NaN, 1)).toBe(item)
178
+ })
179
+
180
+ it('ignores a non-finite value and returns the item unchanged', () => {
181
+ const item = overlay()
182
+ expect(setKeyframe(item, 'scale', 0, Infinity)).toBe(item)
183
+ })
184
+ })
185
+
186
+ describe('removeKeyframe', () => {
187
+ it('removes one point, leaving the rest of the track', () => {
188
+ const item = overlay({
189
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }, { t: 5, value: 2 }] }],
190
+ })
191
+ const next = removeKeyframe(item, 'scale', 0)
192
+ expect(trackFor(next, 'scale')!.points).toEqual([{ t: 5, value: 2 }])
193
+ })
194
+
195
+ it('removing the last point removes the whole track', () => {
196
+ const item = overlay({
197
+ keyframes: [
198
+ { prop: 'scale', points: [{ t: 0, value: 1 }] },
199
+ { prop: 'opacity', points: [{ t: 0, value: 1 }] },
200
+ ],
201
+ })
202
+ const next = removeKeyframe(item, 'scale', 0)
203
+ expect(trackFor(next, 'scale')).toBeUndefined()
204
+ expect(trackFor(next, 'opacity')).toBeDefined() // untouched
205
+ })
206
+
207
+ it('removing the last track removes item.keyframes entirely (undefined, not [])', () => {
208
+ const item = overlay({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }] }] })
209
+ const next = removeKeyframe(item, 'scale', 0)
210
+ expect(next.keyframes).toBeUndefined()
211
+ expect('keyframes' in next).toBe(false)
212
+ })
213
+
214
+ it('is a no-op when t is not present', () => {
215
+ const item = overlay({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }] }] })
216
+ expect(removeKeyframe(item, 'scale', 99)).toBe(item)
217
+ })
218
+
219
+ it('is a no-op when the prop has no track', () => {
220
+ const item = overlay()
221
+ expect(removeKeyframe(item, 'scale', 0)).toBe(item)
222
+ })
223
+
224
+ it('does not mutate the input item, its keyframes array, or its point objects', () => {
225
+ const points = [{ t: 0, value: 1 }, { t: 5, value: 2 }]
226
+ const item = overlay({ keyframes: [{ prop: 'scale', points }] })
227
+ const snapshot = JSON.parse(JSON.stringify(item))
228
+
229
+ removeKeyframe(item, 'scale', 0)
230
+
231
+ expect(item).toEqual(snapshot)
232
+ expect(item.keyframes![0].points).toBe(points)
233
+ })
234
+ })
235
+
236
+ describe('moveKeyframe', () => {
237
+ it('retimes a keyframe, preserving value and easing', () => {
238
+ const item = overlay({
239
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1, easing: 'ease-out' }] }],
240
+ })
241
+ const next = moveKeyframe(item, 'scale', 0, 7)
242
+ expect(trackFor(next, 'scale')!.points).toEqual([{ t: 7, value: 1, easing: 'ease-out' }])
243
+ })
244
+
245
+ it('landing on an occupied t: the moved keyframe wins', () => {
246
+ const item = overlay({
247
+ keyframes: [
248
+ { prop: 'scale', points: [{ t: 0, value: 1 }, { t: 5, value: 9 }] },
249
+ ],
250
+ })
251
+ const next = moveKeyframe(item, 'scale', 0, 5)
252
+ const points = trackFor(next, 'scale')!.points
253
+ expect(points).toHaveLength(1)
254
+ expect(points[0]).toEqual({ t: 5, value: 1 }) // moved point's value (1), not the resident's (9)
255
+ })
256
+
257
+ it('is a no-op when fromT is not present', () => {
258
+ const item = overlay({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }] }] })
259
+ expect(moveKeyframe(item, 'scale', 99, 5)).toBe(item)
260
+ })
261
+
262
+ it('ignores non-finite fromT/toT', () => {
263
+ const item = overlay({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }] }] })
264
+ expect(moveKeyframe(item, 'scale', NaN, 5)).toBe(item)
265
+ expect(moveKeyframe(item, 'scale', 0, Infinity)).toBe(item)
266
+ })
267
+
268
+ it('does not mutate the input item, its keyframes array, or its point objects', () => {
269
+ const points = [{ t: 0, value: 1 }]
270
+ const item = overlay({ keyframes: [{ prop: 'scale', points }] })
271
+ const snapshot = JSON.parse(JSON.stringify(item))
272
+
273
+ moveKeyframe(item, 'scale', 0, 7)
274
+
275
+ expect(item).toEqual(snapshot)
276
+ expect(item.keyframes![0].points).toBe(points)
277
+ })
278
+ })
279
+
280
+ describe('setKeyframeEasing', () => {
281
+ it('sets the outgoing easing on the keyframe at t', () => {
282
+ const item = overlay({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }] }] })
283
+ const next = setKeyframeEasing(item, 'scale', 0, 'ease-in-out')
284
+ expect(trackFor(next, 'scale')!.points[0].easing).toBe('ease-in-out')
285
+ })
286
+
287
+ it('overwrites an existing easing', () => {
288
+ const item = overlay({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1, easing: 'hold' }] }] })
289
+ const next = setKeyframeEasing(item, 'scale', 0, 'linear')
290
+ expect(trackFor(next, 'scale')!.points[0].easing).toBe('linear')
291
+ })
292
+
293
+ it('is a no-op when there is no point at t', () => {
294
+ const item = overlay({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }] }] })
295
+ expect(setKeyframeEasing(item, 'scale', 5, 'hold')).toBe(item)
296
+ })
297
+
298
+ it('does not mutate the input item, its keyframes array, or its point objects', () => {
299
+ const points = [{ t: 0, value: 1 }]
300
+ const item = overlay({ keyframes: [{ prop: 'scale', points }] })
301
+ const snapshot = JSON.parse(JSON.stringify(item))
302
+
303
+ setKeyframeEasing(item, 'scale', 0, 'ease')
304
+
305
+ expect(item).toEqual(snapshot)
306
+ expect(item.keyframes![0].points).toBe(points)
307
+ })
308
+ })
309
+
310
+ describe('enableKeyframing', () => {
311
+ it('seeds a single keyframe at atT with the current static value', () => {
312
+ const item = overlay({ scale: 1.75 })
313
+ const next = enableKeyframing(item, 'scale', 3)
314
+ expect(trackFor(next, 'scale')!.points).toEqual([{ t: 3, value: 1.75 }])
315
+ })
316
+
317
+ it('seeds the prop default when there is no static scalar either', () => {
318
+ const item = overlay()
319
+ const next = enableKeyframing(item, 'opacity', 0)
320
+ expect(trackFor(next, 'opacity')!.points).toEqual([{ t: 0, value: 1 }])
321
+ })
322
+
323
+ it('the overlay does not move: valueAt is identical before and after, at atT', () => {
324
+ const item = overlay({ offsetY: -8 })
325
+ const before = valueAt(item, 'offsetY', 4)
326
+ const next = enableKeyframing(item, 'offsetY', 4)
327
+ const after = valueAt(next, 'offsetY', 4)
328
+ expect(after).toBe(before)
329
+ })
330
+
331
+ it('ignores a non-finite atT', () => {
332
+ const item = overlay()
333
+ expect(enableKeyframing(item, 'scale', NaN)).toBe(item)
334
+ })
335
+
336
+ it('does not mutate the input item', () => {
337
+ const item = overlay({ scale: 2 })
338
+ const snapshot = JSON.parse(JSON.stringify(item))
339
+ enableKeyframing(item, 'scale', 0)
340
+ expect(item).toEqual(snapshot)
341
+ })
342
+
343
+ it('is a no-op when the prop already has keyframes — never resets an existing track', () => {
344
+ const item = overlay({
345
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }, { t: 5, value: 2 }, { t: 9, value: 3 }] }],
346
+ })
347
+ const snapshot = JSON.parse(JSON.stringify(item))
348
+ const next = enableKeyframing(item, 'scale', 5)
349
+
350
+ expect(next).toBe(item)
351
+ expect(next).toEqual(snapshot)
352
+ expect(trackFor(next, 'scale')!.points).toHaveLength(3)
353
+ })
354
+
355
+ it('the explicit reset path — disableKeyframing then enableKeyframing — reseeds a single point at atT', () => {
356
+ const item = overlay({
357
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
358
+ })
359
+ const off = disableKeyframing(item, 'offsetX', 5) // curve value at t=5 is 50, written to the static scalar
360
+ const on = enableKeyframing(off, 'offsetX', 5)
361
+ expect(trackFor(on, 'offsetX')!.points).toEqual([{ t: 5, value: 50 }])
362
+ })
363
+ })
364
+
365
+ describe('disableKeyframing', () => {
366
+ it('removes the track and writes the curve value at atT into the static scalar', () => {
367
+ const item = overlay({
368
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
369
+ })
370
+ const next = disableKeyframing(item, 'offsetX', 5)
371
+ expect(trackFor(next, 'offsetX')).toBeUndefined()
372
+ expect(next.offsetX).toBe(50)
373
+ })
374
+
375
+ it('removing the only track drops item.keyframes entirely', () => {
376
+ const item = overlay({
377
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
378
+ })
379
+ const next = disableKeyframing(item, 'offsetX', 5)
380
+ expect(next.keyframes).toBeUndefined()
381
+ })
382
+
383
+ it('the overlay does not jump: valueAt is identical before and after, at atT', () => {
384
+ const item = overlay({
385
+ keyframes: [{ prop: 'rotation', points: [{ t: 0, value: 0 }, { t: 10, value: 90 }] }],
386
+ })
387
+ const before = valueAt(item, 'rotation', 3)
388
+ const next = disableKeyframing(item, 'rotation', 3)
389
+ const after = valueAt(next, 'rotation', 3)
390
+ expect(after).toBe(before)
391
+ })
392
+
393
+ it('ignores a non-finite atT', () => {
394
+ const item = overlay({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }] }] })
395
+ expect(disableKeyframing(item, 'scale', NaN)).toBe(item)
396
+ })
397
+
398
+ it('does not mutate the input item, its keyframes array, or its point objects', () => {
399
+ const points = [{ t: 0, value: 1 }, { t: 10, value: 2 }]
400
+ const item = overlay({ keyframes: [{ prop: 'scale', points }] })
401
+ const snapshot = JSON.parse(JSON.stringify(item))
402
+
403
+ disableKeyframing(item, 'scale', 5)
404
+
405
+ expect(item).toEqual(snapshot)
406
+ expect(item.keyframes![0].points).toBe(points)
407
+ })
408
+ })
409
+
410
+ // ── Round-trip: prove the module's output is actually consumable by the
411
+ // read path (`sampleTrack`, imported directly from timeline-core here). ────
412
+
413
+ describe('round-trip with sampleTrack', () => {
414
+ it('a track built through several ops reads back the expected curve', () => {
415
+ let item = overlay()
416
+ item = setKeyframe(item, 'opacity', 10, 0)
417
+ item = setKeyframe(item, 'opacity', 0, 1, 'linear')
418
+ item = moveKeyframe(item, 'opacity', 10, 8)
419
+ item = setKeyframeEasing(item, 'opacity', 0, 'hold')
420
+
421
+ const track = trackFor(item, 'opacity')!
422
+ expect(track.points).toEqual([
423
+ { t: 0, value: 1, easing: 'hold' },
424
+ { t: 8, value: 0 },
425
+ ])
426
+
427
+ // 'hold' is step-end: the value stays 1 for the whole segment and only
428
+ // reaches 0 exactly at the next keyframe's own t.
429
+ expect(sampleTrack(track, 0)).toBe(1)
430
+ expect(sampleTrack(track, 4)).toBe(1)
431
+ expect(sampleTrack(track, 7.999)).toBe(1)
432
+ expect(sampleTrack(track, 8)).toBe(0)
433
+ expect(sampleTrack(track, 100)).toBe(0) // clamped past the last keyframe
434
+ })
435
+
436
+ it('setKeyframe followed by removeKeyframe leaves sampleTrack with the sentinel (no track)', () => {
437
+ let item = overlay()
438
+ item = setKeyframe(item, 'scale', 0, 2)
439
+ item = removeKeyframe(item, 'scale', 0)
440
+ expect(trackFor(item, 'scale')).toBeUndefined()
441
+ expect(sampleTrack(trackFor(item, 'scale'), 0)).toBeUndefined()
442
+ })
443
+ })
444
+
445
+ describe('canKeyframe — the single gate on which items support keyframing', () => {
446
+ it('accepts an overlay', () => {
447
+ expect(canKeyframe({ id: 'o', type: 'overlay', start: 0, end: 5 } as VisualItem)).toBe(true)
448
+ })
449
+
450
+ it('ACCEPTS video and image items (SP9d)', () => {
451
+ // Was false, and the reason it was false is gone. The ffmpeg path used to
452
+ // emit ONE static box per segment with no per-frame hook; encode-segment.js
453
+ // now compiles a curve into a time-varying filter expression, and the
454
+ // preview animates clips to match. Three coordinated changes, landed
455
+ // together: this predicate, that preview branch, and the renderer.
456
+ expect(canKeyframe({ id: 'v', type: 'video', start: 0, end: 5 } as VisualItem)).toBe(true)
457
+ expect(canKeyframe({ id: 'i', type: 'image', start: 0, end: 5 } as VisualItem)).toBe(true)
458
+ })
459
+
460
+ it('still rejects kinds with no transform at all', () => {
461
+ expect(canKeyframe({ id: 'a', type: 'audio', start: 0, end: 5 } as unknown as VisualItem)).toBe(false)
462
+ })
463
+
464
+ it('accepts a video item carrying a keyframes array', () => {
465
+ const kf = {
466
+ id: 'v', type: 'video', start: 0, end: 5,
467
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }] }],
468
+ } as unknown as VisualItem
469
+ expect(canKeyframe(kf)).toBe(true)
470
+ })
471
+
472
+ it('rejects null/undefined without throwing', () => {
473
+ expect(canKeyframe(null as unknown as VisualItem)).toBe(false)
474
+ expect(canKeyframe(undefined as unknown as VisualItem)).toBe(false)
475
+ })
476
+ })
477
+
478
+ describe('valueAt — samples with the item\'s OWN kind', () => {
479
+ it('reads the five props identically for any item kind', () => {
480
+ // `geometryAt`'s `kind` only drives `fit`, which is not keyframeable, so
481
+ // this is behaviour-preserving today. It stops the function LYING about
482
+ // the item's kind, which is what makes Task 1 a safe foundation.
483
+ const base = { id: 'x', start: 0, end: 10, scale: 1.5, offsetX: 20 }
484
+ const asOverlay = { ...base, type: 'overlay' } as VisualItem
485
+ const asVideo = { ...base, type: 'video' } as VisualItem
486
+ expect(valueAt(asOverlay, 'scale', 0)).toBe(1.5)
487
+ expect(valueAt(asVideo, 'scale', 0)).toBe(1.5)
488
+ expect(valueAt(asVideo, 'offsetX', 0)).toBe(20)
489
+ })
490
+ })
491
+
492
+ describe('removeKeyframesAt — removes every prop keyed at one instant, without jumping', () => {
493
+ it('removes the point from every prop that has one at t', () => {
494
+ const item = {
495
+ id: 'o', type: 'overlay', start: 0, end: 10, scale: 9, offsetX: 9,
496
+ keyframes: [
497
+ { prop: 'scale', points: [{ t: 0, value: 1 }, { t: 5, value: 2 }] },
498
+ { prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 5, value: 50 }] },
499
+ { prop: 'opacity', points: [{ t: 2, value: 0.5 }] },
500
+ ],
501
+ } as unknown as VisualItem
502
+
503
+ const next = removeKeyframesAt(item, 5)
504
+ expect(trackFor(next, 'scale')!.points.map(p => p.t)).toEqual([0])
505
+ expect(trackFor(next, 'offsetX')!.points.map(p => p.t)).toEqual([0])
506
+ // A prop with no point at t is untouched.
507
+ expect(trackFor(next, 'opacity')!.points.map(p => p.t)).toEqual([2])
508
+ })
509
+
510
+ it('freezes the sampled value when t was a track\'s LAST point, so nothing jumps', () => {
511
+ // THE BUG THIS FIXES: plain `removeKeyframe` drops the track without
512
+ // writing the scalar, so the overlay snaps back to the stale `scale: 9`.
513
+ const item = {
514
+ id: 'o', type: 'overlay', start: 0, end: 10, scale: 9,
515
+ keyframes: [{ prop: 'scale', points: [{ t: 3, value: 2 }] }],
516
+ } as unknown as VisualItem
517
+
518
+ const next = removeKeyframesAt(item, 3)
519
+ expect(hasKeyframes(next, 'scale')).toBe(false)
520
+ expect(next.scale).toBe(2) // the curve's value, NOT the stale 9
521
+ })
522
+
523
+ it('is a no-op when no prop has a point at t', () => {
524
+ const item = {
525
+ id: 'o', type: 'overlay', start: 0, end: 10,
526
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }] }],
527
+ } as unknown as VisualItem
528
+ expect(removeKeyframesAt(item, 7)).toBe(item)
529
+ })
530
+
531
+ it('drops item.keyframes entirely when the last point of the last track goes', () => {
532
+ const item = {
533
+ id: 'o', type: 'overlay', start: 0, end: 10, scale: 9,
534
+ keyframes: [{ prop: 'scale', points: [{ t: 1, value: 3 }] }],
535
+ } as unknown as VisualItem
536
+ expect(removeKeyframesAt(item, 1).keyframes).toBeUndefined()
537
+ })
538
+ })
539
+
540
+ describe('isUniformScale — how an overlay is AUTHORED, not what its numbers are', () => {
541
+ it('is true for an item carrying no per-axis scale at all', () => {
542
+ expect(isUniformScale(overlay())).toBe(true)
543
+ expect(isUniformScale(overlay({ scale: 1.2 }))).toBe(true)
544
+ })
545
+
546
+ it('is false for either per-axis SCALAR on its own', () => {
547
+ expect(isUniformScale(overlay({ scaleX: 1.2 }))).toBe(false)
548
+ expect(isUniformScale(overlay({ scaleY: 1.2 }))).toBe(false)
549
+ })
550
+
551
+ it('is false for either per-axis TRACK, even with no scalar', () => {
552
+ const keyed = (prop: 'scaleX' | 'scaleY') =>
553
+ overlay({ keyframes: [{ prop, points: [{ t: 0, value: 1 }] }] })
554
+ expect(isUniformScale(keyed('scaleX'))).toBe(false)
555
+ expect(isUniformScale(keyed('scaleY'))).toBe(false)
556
+ })
557
+
558
+ it('stays false when the two axes happen to be EQUAL', () => {
559
+ // The whole reason absence is the test. An overlay the operator unlocked on
560
+ // purpose and left at 120%/120% is authored per-axis; an equality test
561
+ // would silently re-lock it the moment the two numbers met.
562
+ expect(isUniformScale(overlay({ scaleX: 1.2, scaleY: 1.2 }))).toBe(false)
563
+ })
564
+
565
+ it('ignores an EMPTY per-axis track, which is not an animation', () => {
566
+ // withTrack never leaves one behind, but a hand-edited project.json can.
567
+ expect(isUniformScale(overlay({ keyframes: [{ prop: 'scaleX', points: [] }] }))).toBe(true)
568
+ })
569
+
570
+ it('is unmoved by tracks on other props', () => {
571
+ expect(isUniformScale(overlay({
572
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }, { t: 5, value: 2 }] }],
573
+ }))).toBe(true)
574
+ })
575
+ })
576
+
577
+ describe('transformProps — the set an all-props action may walk', () => {
578
+ it('gives a uniform item `scale` and NEITHER per-axis prop', () => {
579
+ const props = transformProps(overlay({ scale: 1.2 }))
580
+ expect(props).toEqual(['offsetX', 'offsetY', 'scale', 'rotation', 'opacity'])
581
+ })
582
+
583
+ it('gives a per-axis item both axes and NOT `scale`', () => {
584
+ const props = transformProps(overlay({ scaleX: 1.5, scaleY: 0.5 }))
585
+ expect(props).toEqual(['offsetX', 'offsetY', 'scaleX', 'scaleY', 'rotation', 'opacity'])
586
+ })
587
+
588
+ it('never mixes the uniform and per-axis scale props', () => {
589
+ // The invariant, whatever the item: the scale props returned are EITHER
590
+ // exactly ['scale'] OR exactly ['scaleX','scaleY']. A list carrying all
591
+ // three would have one shadowing the others.
592
+ const items = [
593
+ overlay(),
594
+ overlay({ scale: 2 }),
595
+ overlay({ scaleX: 1 }),
596
+ overlay({ scaleY: 1 }),
597
+ overlay({ scale: 2, scaleX: 1, scaleY: 1 }),
598
+ overlay({ keyframes: [{ prop: 'scaleX', points: [{ t: 0, value: 1 }] }] }),
599
+ overlay({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }] }] }),
600
+ ]
601
+ for (const item of items) {
602
+ const scaleProps = transformProps(item).filter(p => p.startsWith('scale'))
603
+ expect([['scale'], ['scaleX', 'scaleY']]).toContainEqual(scaleProps)
604
+ }
605
+ })
606
+
607
+ it('returns the same prop ORDER for both shapes, so the two agree bar the scale slot', () => {
608
+ const strip = (item: VisualItem) => transformProps(item).filter(p => !p.startsWith('scale'))
609
+ expect(strip(overlay())).toEqual(strip(overlay({ scaleX: 1, scaleY: 1 })))
610
+ expect(strip(overlay())).toEqual(['offsetX', 'offsetY', 'rotation', 'opacity'])
611
+ })
612
+
613
+ it('keying every returned prop leaves a uniform animation still animating', () => {
614
+ // The failure this function exists to prevent, driven end to end: a
615
+ // one-point scaleX track would shadow the `scale` track and freeze the zoom.
616
+ const item = overlay({
617
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }, { t: 10, value: 3 }] }],
618
+ })
619
+ let next = item
620
+ for (const prop of transformProps(item)) {
621
+ next = setKeyframe(enableKeyframing(next, prop, 5), prop, 5, valueAt(item, prop, 5))
622
+ }
623
+
624
+ expect(trackFor(next, 'scaleX')).toBeUndefined()
625
+ expect(valueAt(next, 'scaleX', 0)).toBe(1)
626
+ expect(valueAt(next, 'scaleX', 10)).toBe(3)
627
+ expect(valueAt(next, 'scaleY', 10)).toBe(3)
628
+ })
629
+
630
+ it('keying every returned prop actually bites on a per-axis item', () => {
631
+ // The symmetric failure: `scale` is shadowed there, so keying it would look
632
+ // like the gesture did nothing.
633
+ const item = overlay({ scaleX: 1.5, scaleY: 0.5 })
634
+ let next = item
635
+ for (const prop of transformProps(item)) {
636
+ next = setKeyframe(enableKeyframing(next, prop, 5), prop, 5, valueAt(item, prop, 5))
637
+ }
638
+
639
+ expect(trackFor(next, 'scaleX')!.points).toEqual([{ t: 5, value: 1.5 }])
640
+ expect(trackFor(next, 'scaleY')!.points).toEqual([{ t: 5, value: 0.5 }])
641
+ expect(trackFor(next, 'scale')).toBeUndefined()
642
+ })
643
+ })