@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,174 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * The fade-shape picker — a right-click (Vegas' own gesture) on an audio
4
+ * bar's fade GRIP opens a small menu of three ICON options (Linear /
5
+ * Logarithmic / Exponential — see `FadeCurveIcon` in Timeline.tsx), each
6
+ * naming its shape via `title`/`aria-label` rather than visible text;
7
+ * picking one commits `fadeInCurve`/`fadeOutCurve` as ONE undo entry.
8
+ * Options are selected here by their accessible NAME (`getByRole('button',
9
+ * {name: ...})`, which resolves off `aria-label`), not by visible text.
10
+ *
11
+ * `TimelineCanvas`'s `contextmenu` handler does its own hit-test and calls
12
+ * `onFadeCurveMenu` with CLIENT coordinates; `Timeline` owns the menu's
13
+ * open/closed state and renders it as a `position: fixed` DOM overlay. jsdom
14
+ * lays everything out at 0×0, so the surface's rect is stubbed to a real one
15
+ * via `installCanvasHarness` (see `_canvasSelect.ts`).
16
+ */
17
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
18
+ import { render, cleanup, fireEvent, screen } from '@testing-library/react'
19
+ import type { AudioTrack } from '../../../schema'
20
+ import type { Project } from '../../../types'
21
+ import { createPlaybackClock } from '../../playback-clock'
22
+ import Timeline from '../Timeline'
23
+ import { AUDIO_ITEM_INSET_PX, computeTimelineLayout } from '../canvas/draw'
24
+ import { installCanvasHarness, SURFACE_LEFT, timeToClientX } from './_canvasSelect'
25
+
26
+ let uninstall: () => void
27
+
28
+ beforeEach(() => {
29
+ uninstall = installCanvasHarness()
30
+ })
31
+
32
+ afterEach(() => {
33
+ cleanup()
34
+ uninstall()
35
+ })
36
+
37
+ const AUDIO_TRACK: AudioTrack = { id: 'a0', src: 'v.mp3', start: 0, end: 10, fadeIn: 2 }
38
+
39
+ /** `track` defaults to `AUDIO_TRACK` — only its `fadeInCurve`/`fadeOutCurve`
40
+ * ever varies between tests (e.g. the highlight test), never its timing, so
41
+ * `fadeInGripClientPoint` below can keep reading `AUDIO_TRACK`'s own
42
+ * start/fadeIn unconditionally. */
43
+ function makeProject(track: AudioTrack = AUDIO_TRACK): Project {
44
+ return {
45
+ id: 'p1',
46
+ status: 'draft',
47
+ settings: { resolution: [1080, 1920], fps: 30 },
48
+ audio: { tracks: [track] },
49
+ } as unknown as Project
50
+ }
51
+
52
+ /** The fade-in grip's CLIENT (x, y) for `AUDIO_TRACK`, derived the same way
53
+ * `hit-test.ts`'s `audioFadeGripX`/`audioFadeGripZone` do — purely from
54
+ * time and the lane's own y, with no clip-body gutter math (the grip's HIT
55
+ * zone is defined in time, not pixels; see hit-test.ts). `timeToClientX`
56
+ * gives the SAME fit-to-view viewport the mounted surface settles to on
57
+ * render; only the audio-bar's own top inset (`AUDIO_ITEM_INSET_PX`) is
58
+ * genuinely fade-specific and stays local. */
59
+ function fadeInGripClientPoint(project: Project) {
60
+ const clientX = timeToClientX(project, AUDIO_TRACK.start + (AUDIO_TRACK.fadeIn ?? 0))
61
+ const lane = computeTimelineLayout(project).lanes[0]
62
+ const barTop = lane.y + AUDIO_ITEM_INSET_PX
63
+ return { clientX, clientY: barTop + 2 }
64
+ }
65
+
66
+ function mount(track: AudioTrack = AUDIO_TRACK) {
67
+ const clock = createPlaybackClock()
68
+ const onProjectChange = vi.fn()
69
+ const onOverlayEdit = vi.fn()
70
+ const project = makeProject(track)
71
+ const utils = render(
72
+ <Timeline
73
+ project={project}
74
+ clock={clock}
75
+ onProjectChange={onProjectChange}
76
+ onOverlayEdit={onOverlayEdit}
77
+ />,
78
+ )
79
+ const surface = utils.container.querySelector('[data-timeline-canvas]') as HTMLElement
80
+ return { clock, onProjectChange, onOverlayEdit, surface, project, ...utils }
81
+ }
82
+
83
+ /** The picker's three option buttons expose their shape as the ACCESSIBLE
84
+ * NAME (via `aria-label`, with a matching `title` for the hover tooltip) —
85
+ * never as visible text — so every lookup here goes through
86
+ * `getByRole('button', {name})` rather than `getByText`. */
87
+ function shapeButton(name: 'Linear' | 'Logarithmic' | 'Exponential') {
88
+ return screen.getByRole('button', { name })
89
+ }
90
+ function queryShapeButton(name: 'Linear' | 'Logarithmic' | 'Exponential') {
91
+ return screen.queryByRole('button', { name })
92
+ }
93
+
94
+ describe('Timeline — fade-shape picker (right-click a fade grip)', () => {
95
+ it('opens the picker with three shape ICON options on a fade-grip right-click', () => {
96
+ const { surface, project } = mount()
97
+ const point = fadeInGripClientPoint(project)
98
+ fireEvent.contextMenu(surface, point)
99
+ expect(shapeButton('Linear')).toBeInTheDocument()
100
+ expect(shapeButton('Logarithmic')).toBeInTheDocument()
101
+ expect(shapeButton('Exponential')).toBeInTheDocument()
102
+ // Icon-only: the shape name is discoverable via title/aria-label, not as
103
+ // rendered text content anywhere in the menu.
104
+ expect(screen.queryByText('Linear')).not.toBeInTheDocument()
105
+ })
106
+
107
+ it('does NOT open the picker from a right-click elsewhere on the bar', () => {
108
+ const { surface } = mount()
109
+ // Well clear of the fade-in grip's small top-corner zone (see
110
+ // FADE_GRIP_ZONE_HEIGHT_PX) — the middle of the bar, vertically.
111
+ fireEvent.contextMenu(surface, { clientX: SURFACE_LEFT + 500, clientY: 100 })
112
+ expect(queryShapeButton('Linear')).not.toBeInTheDocument()
113
+ })
114
+
115
+ it('picking a shape commits fadeInCurve as ONE undo entry and closes the menu', () => {
116
+ const { surface, onProjectChange, onOverlayEdit, project } = mount()
117
+ const point = fadeInGripClientPoint(project)
118
+ fireEvent.contextMenu(surface, point)
119
+
120
+ fireEvent.click(shapeButton('Logarithmic'))
121
+
122
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
123
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
124
+ const committed = onOverlayEdit.mock.calls[0][0] as Project
125
+ expect(committed.audio?.tracks[0].fadeInCurve).toBe('log')
126
+ // fadeOutCurve is untouched — only the side that was right-clicked changes.
127
+ expect(committed.audio?.tracks[0].fadeOutCurve).toBeUndefined()
128
+ expect(queryShapeButton('Linear')).not.toBeInTheDocument()
129
+ })
130
+
131
+ it('Escape closes the picker without committing anything', () => {
132
+ const { surface, onProjectChange, onOverlayEdit, project } = mount()
133
+ fireEvent.contextMenu(surface, fadeInGripClientPoint(project))
134
+ expect(shapeButton('Linear')).toBeInTheDocument()
135
+
136
+ fireEvent.keyDown(document, { key: 'Escape' })
137
+
138
+ expect(queryShapeButton('Linear')).not.toBeInTheDocument()
139
+ expect(onProjectChange).not.toHaveBeenCalled()
140
+ expect(onOverlayEdit).not.toHaveBeenCalled()
141
+ })
142
+
143
+ it('clicking outside the menu closes it without touching the project', () => {
144
+ const { surface, onProjectChange, onOverlayEdit, project } = mount()
145
+ fireEvent.contextMenu(surface, fadeInGripClientPoint(project))
146
+ expect(shapeButton('Linear')).toBeInTheDocument()
147
+
148
+ // The full-viewport backdrop is what "click elsewhere" actually hits —
149
+ // fixed-positioned, so it's the element under the pointer wherever on
150
+ // screen the click lands.
151
+ fireEvent.click(screen.getByTestId('fade-curve-menu-backdrop'))
152
+
153
+ expect(queryShapeButton('Linear')).not.toBeInTheDocument()
154
+ expect(onProjectChange).not.toHaveBeenCalled()
155
+ expect(onOverlayEdit).not.toHaveBeenCalled()
156
+ })
157
+
158
+ it('highlights the DEFAULT shape (exp) when no curve is set yet', () => {
159
+ const { surface, project } = mount()
160
+ fireEvent.contextMenu(surface, fadeInGripClientPoint(project))
161
+ expect(shapeButton('Exponential')).toHaveAttribute('aria-pressed', 'true')
162
+ expect(shapeButton('Linear')).toHaveAttribute('aria-pressed', 'false')
163
+ expect(shapeButton('Logarithmic')).toHaveAttribute('aria-pressed', 'false')
164
+ })
165
+
166
+ it('highlights whichever shape is already set on the track being edited', () => {
167
+ const track: AudioTrack = { ...AUDIO_TRACK, fadeInCurve: 'log' }
168
+ const { surface, project } = mount(track)
169
+ fireEvent.contextMenu(surface, fadeInGripClientPoint(project))
170
+ expect(shapeButton('Logarithmic')).toHaveAttribute('aria-pressed', 'true')
171
+ expect(shapeButton('Linear')).toHaveAttribute('aria-pressed', 'false')
172
+ expect(shapeButton('Exponential')).toHaveAttribute('aria-pressed', 'false')
173
+ })
174
+ })
@@ -0,0 +1,273 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * Delete removes the selected keyframe (SP9b T3.6) — the precedence seam
4
+ * between `timeline.delete-keyframe` and the pre-existing
5
+ * `timeline.delete-selection` binding (see `Timeline.keymap.test.tsx`).
6
+ * `useKeymap` is first-match-wins: with a keyframe diamond selected, Delete
7
+ * must remove ONLY that keyframe and leave the clip alone; with no keyframe
8
+ * selected it must fall straight through to the existing clip/caption/audio
9
+ * delete, unchanged.
10
+ *
11
+ * A keyframe diamond is selected via the REAL gesture — a double-click on it
12
+ * (`pointer-machine.ts`'s `doubleClick` case, `'keyframe'` hit → the
13
+ * `selectKeyframe` effect → `TimelineCanvas`'s `onSelectKeyframe` →
14
+ * `Timeline`'s `setSelectedKeyframe`) — mirroring how
15
+ * `Timeline.keyframeMenu.test.tsx` drives its own right-click gesture rather
16
+ * than reaching into Timeline's state directly.
17
+ */
18
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
19
+ import { render, cleanup, fireEvent } from '@testing-library/react'
20
+ import type { Project } from '../../../types'
21
+ import { createPlaybackClock } from '../../playback-clock'
22
+ import Timeline from '../Timeline'
23
+ import { computeTimelineLayout } from '../canvas/draw'
24
+ import { canvasSurface, focusCanvasRoot, installCanvasHarness, timeToClientX } from './_canvasSelect'
25
+
26
+ let uninstall: () => void
27
+
28
+ beforeEach(() => {
29
+ uninstall = installCanvasHarness()
30
+ })
31
+
32
+ afterEach(() => {
33
+ cleanup()
34
+ uninstall()
35
+ })
36
+
37
+ /**
38
+ * clip-0 (0s-2s, video, plain) + o0 (2s-4s, overlay) keyframed on `scale` at
39
+ * item-relative t=0.5 AND t=1.5. Two points on the one track that's ever
40
+ * touched by the "ordinary" tests below, so removing one point never hits
41
+ * the last-point/freeze branch — that branch gets its own dedicated fixture
42
+ * (`makeProjectWithFreezeCandidate`) rather than being an incidental side
43
+ * effect here.
44
+ */
45
+ function makeProject(): Project {
46
+ return {
47
+ id: 'p1',
48
+ status: 'draft',
49
+ settings: { resolution: [1080, 1920], fps: 30 },
50
+ tracks: [{
51
+ id: 'trk-0',
52
+ items: [
53
+ { id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 2, inPoint: 0, outPoint: 2 },
54
+ {
55
+ id: 'o0', type: 'overlay', start: 2, end: 4,
56
+ keyframes: [
57
+ { prop: 'scale', points: [{ t: 0.5, value: 1 }, { t: 1.5, value: 2 }] },
58
+ ],
59
+ },
60
+ ],
61
+ }],
62
+ } as unknown as Project
63
+ }
64
+
65
+ /**
66
+ * o0 additionally keyframed on `opacity` with a SINGLE point at t=0.5 — the
67
+ * same instant `scale` also holds a point at, so the diamond there is the
68
+ * union of both props (`keyframeUnionTimes`). `opacity`'s stale static
69
+ * scalar (0.1) is deliberately far from the curve's value (0.4): if removal
70
+ * routed through the plain multi-point path instead of
71
+ * `removeKeyframesAt`'s last-point/`disableKeyframing` branch, the item would
72
+ * snap back to the stale 0.1 the instant the keyframe went away.
73
+ */
74
+ function makeProjectWithFreezeCandidate(): Project {
75
+ const project = makeProject()
76
+ const overlay = project.tracks![0].items[1]
77
+ return {
78
+ ...project,
79
+ tracks: [{
80
+ ...project.tracks![0],
81
+ items: [
82
+ project.tracks![0].items[0],
83
+ {
84
+ ...overlay,
85
+ opacity: 0.1,
86
+ keyframes: [
87
+ ...overlay.keyframes!,
88
+ { prop: 'opacity', points: [{ t: 0.5, value: 0.4 }] },
89
+ ],
90
+ },
91
+ ],
92
+ }],
93
+ } as unknown as Project
94
+ }
95
+
96
+ function mount(project: Project, selectedIds: string[]) {
97
+ const clock = createPlaybackClock()
98
+ const onProjectChange = vi.fn()
99
+ const onOverlayEdit = vi.fn()
100
+ const onSelectIds = vi.fn()
101
+ const utils = render(
102
+ <Timeline
103
+ project={project}
104
+ clock={clock}
105
+ onProjectChange={onProjectChange}
106
+ onOverlayEdit={onOverlayEdit}
107
+ selectedIds={selectedIds}
108
+ onSelectIds={onSelectIds}
109
+ />,
110
+ )
111
+ return { clock, onProjectChange, onOverlayEdit, onSelectIds, surface: canvasSurface(utils.container), ...utils }
112
+ }
113
+
114
+ /** CLIENT (x, y) for o0's diamond at item-relative `t` — the same "fit-to-view
115
+ * viewport a freshly mounted surface settles to, plus a couple px above the
116
+ * row's bottom edge" math `Timeline.keyframeMenu.test.tsx`'s own
117
+ * `keyframeClientPoint` uses. */
118
+ function keyframeClientPoint(project: Project, t: number) {
119
+ const row = computeTimelineLayout(project).rows[0]
120
+ const clientX = timeToClientX(project, 2 + t) // o0.start is 2
121
+ return { clientX, clientY: row.y + row.height - 2 }
122
+ }
123
+
124
+ /** Select a keyframe diamond the honest way: a real double-click dispatched
125
+ * on the canvas surface, driving `pointer-machine.ts`'s `doubleClick` →
126
+ * `selectKeyframe` → `TimelineCanvas`'s `onSelectKeyframe` →
127
+ * `Timeline`'s `setSelectedKeyframe`. Never reaches into Timeline's state. */
128
+ function selectKeyframeAt(surface: HTMLElement, project: Project, t: number) {
129
+ fireEvent.doubleClick(surface, keyframeClientPoint(project, t))
130
+ }
131
+
132
+ describe('Timeline — Delete removes the selected keyframe', () => {
133
+ it('Delete removes the selected keyframe and leaves the clip alone', () => {
134
+ const project = makeProject()
135
+ const { surface, container, onProjectChange, onOverlayEdit } = mount(project, ['o0'])
136
+ selectKeyframeAt(surface, project, 0.5)
137
+ focusCanvasRoot(container)
138
+
139
+ fireEvent.keyDown(document.body, { key: 'Delete' })
140
+
141
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
142
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
143
+ const updated = onProjectChange.mock.calls[0][0] as Project
144
+ const items = updated.tracks![0].items
145
+ // Both items survive — nothing was deleted from the track.
146
+ expect(items.map(i => i.id)).toEqual(['clip-0', 'o0'])
147
+ const overlay = items.find(i => i.id === 'o0')!
148
+ const scaleTrack = overlay.keyframes!.find(t => t.prop === 'scale')!
149
+ // Only the t=0.5 point is gone; t=1.5 remains untouched.
150
+ expect(scaleTrack.points.map(p => p.t)).toEqual([1.5])
151
+ })
152
+
153
+ it('Delete still deletes the clip when NO keyframe is selected', () => {
154
+ const project = makeProject()
155
+ const { surface: _surface, container, onProjectChange, onOverlayEdit, onSelectIds } = mount(project, ['clip-0'])
156
+ // No double-click — selectedKeyframe stays null, so the fall-through
157
+ // to `timeline.delete-selection` is exercised.
158
+ focusCanvasRoot(container)
159
+
160
+ fireEvent.keyDown(document.body, { key: 'Delete' })
161
+
162
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
163
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
164
+ expect(onSelectIds).toHaveBeenCalledWith([])
165
+ const updated = onProjectChange.mock.calls[0][0] as Project
166
+ expect(updated.tracks![0].items.find(i => i.id === 'clip-0')).toBeUndefined()
167
+ })
168
+
169
+ it('Delete removes only the keyframe, not the clip, when both are selected', () => {
170
+ // A keyframe can only be selected while its OWNING item is also selected
171
+ // (Timeline.tsx clears `selectedKeyframe` the instant its item leaves
172
+ // `selectedIds` — see the effect right after the `useState`), so "both
173
+ // selected" is the NORMAL case here, not a rare edge case. If
174
+ // `timeline.delete-keyframe` were ordered AFTER `timeline.delete-selection`
175
+ // (or its guard were wrong), first-match-wins would hand every one of
176
+ // these presses to the clip-delete binding instead, deleting o0 outright.
177
+ const project = makeProject()
178
+ const { surface, container, onProjectChange } = mount(project, ['o0'])
179
+ selectKeyframeAt(surface, project, 1.5)
180
+ focusCanvasRoot(container)
181
+
182
+ fireEvent.keyDown(document.body, { key: 'Delete' })
183
+
184
+ const updated = onProjectChange.mock.calls[0][0] as Project
185
+ const items = updated.tracks![0].items
186
+ expect(items).toHaveLength(2)
187
+ expect(items.map(i => i.id)).toContain('o0')
188
+ const overlay = items.find(i => i.id === 'o0')!
189
+ const scaleTrack = overlay.keyframes!.find(t => t.prop === 'scale')
190
+ expect(scaleTrack?.points.map(p => p.t)).toEqual([0.5])
191
+ })
192
+
193
+ it('clears the keyframe selection after removing it, so a second Delete hits the clip', () => {
194
+ const project = makeProject()
195
+ const { surface, container, onProjectChange, onSelectIds } = mount(project, ['o0'])
196
+ selectKeyframeAt(surface, project, 0.5)
197
+ focusCanvasRoot(container)
198
+
199
+ fireEvent.keyDown(document.body, { key: 'Delete' }) // removes the keyframe
200
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
201
+ // The FIRST press must NOT have touched the clip — this is what
202
+ // distinguishes "selection was cleared" from "Delete just deletes the
203
+ // clip regardless," which would make the second assertion below pass
204
+ // even if this whole feature were unwired.
205
+ const firstCommit = onProjectChange.mock.calls[0][0] as Project
206
+ expect(firstCommit.tracks![0].items.find(i => i.id === 'o0')).toBeDefined()
207
+
208
+ fireEvent.keyDown(document.body, { key: 'Delete' }) // second press: no keyframe selected anymore
209
+ expect(onProjectChange).toHaveBeenCalledTimes(2)
210
+ expect(onSelectIds).toHaveBeenCalledWith([])
211
+ const secondCommit = onProjectChange.mock.calls[1][0] as Project
212
+ expect(secondCommit.tracks![0].items.find(i => i.id === 'o0')).toBeUndefined()
213
+ })
214
+
215
+ it('freezes the value when the removed keyframe was a track\'s last point', () => {
216
+ const project = makeProjectWithFreezeCandidate()
217
+ const { surface, container, onProjectChange } = mount(project, ['o0'])
218
+ // t=0.5 is the union of scale's (multi-point) and opacity's (single-point,
219
+ // "last point") tracks — one diamond, one Delete, two different removal
220
+ // branches inside `removeKeyframesAt` exercised at once.
221
+ selectKeyframeAt(surface, project, 0.5)
222
+ focusCanvasRoot(container)
223
+
224
+ fireEvent.keyDown(document.body, { key: 'Delete' })
225
+
226
+ const updated = onProjectChange.mock.calls[0][0] as Project
227
+ const overlay = updated.tracks![0].items.find(i => i.id === 'o0')!
228
+ // opacity's only point is gone — its track is dropped entirely...
229
+ expect(overlay.keyframes?.find(t => t.prop === 'opacity')).toBeUndefined()
230
+ // ...and item.opacity holds the CURVE's sampled value at t=0.5 (0.4), not
231
+ // the stale pre-keyframing scalar (0.1) that was sitting there before.
232
+ expect(overlay.opacity).toBeCloseTo(0.4, 5)
233
+ // scale is unaffected: it had a SECOND point (t=1.5) at removal time, so
234
+ // it takes the ordinary multi-point branch and survives with that point.
235
+ const scaleTrack = overlay.keyframes!.find(t => t.prop === 'scale')!
236
+ expect(scaleTrack.points.map(p => p.t)).toEqual([1.5])
237
+ })
238
+
239
+ it('falls through to clip deletion when the selected keyframe no longer exists', () => {
240
+ // The selection can go stale WITHOUT its item leaving `selectedIds` —
241
+ // a diamond drag, the right-click menu, undo. Nothing draws as selected
242
+ // then, so Delete must delete the clip rather than silently do nothing.
243
+ const project = makeProject()
244
+ const props = {
245
+ clock: createPlaybackClock(),
246
+ onProjectChange: vi.fn(),
247
+ onOverlayEdit: vi.fn(),
248
+ selectedIds: ['o0'],
249
+ onSelectIds: vi.fn(),
250
+ }
251
+ const { container, rerender } = render(<Timeline project={project} {...props} />)
252
+ selectKeyframeAt(canvasSurface(container), project, 0.5)
253
+
254
+ // Same item, still selected — but its t=0.5 keyframe is gone.
255
+ const pruned = {
256
+ ...project,
257
+ tracks: [{
258
+ ...project.tracks![0],
259
+ items: project.tracks![0].items.map(i => i.id === 'o0'
260
+ ? { ...i, keyframes: [{ prop: 'scale', points: [{ t: 1.5, value: 2 }] }] }
261
+ : i),
262
+ }],
263
+ } as unknown as Project
264
+ rerender(<Timeline project={pruned} {...props} />)
265
+ focusCanvasRoot(container)
266
+
267
+ fireEvent.keyDown(document.body, { key: 'Delete' })
268
+
269
+ expect(props.onProjectChange).toHaveBeenCalledTimes(1)
270
+ const updated = props.onProjectChange.mock.calls[0][0] as Project
271
+ expect(updated.tracks![0].items.find(i => i.id === 'o0')).toBeUndefined()
272
+ })
273
+ })
@@ -0,0 +1,215 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * Selection follows a retimed diamond (CapCut-correct operator decision):
4
+ * dragging the SELECTED diamond to a new time must keep it selected AT THAT
5
+ * NEW TIME — not drop the selection because `selectedKeyframe` still names
6
+ * the OLD `t`, which `moveKeyframe` has just vacated.
7
+ *
8
+ * Exercised end to end with a REAL drag (mousedown → mousemove(s) →
9
+ * mouseup on `document`, exactly as `TimelineCanvas` wires its own gesture —
10
+ * see `_canvasSelect.ts`'s `dragCanvasItem`), then a REAL `Delete` keypress,
11
+ * mirroring how `Timeline.keyframeDelete.test.tsx` proves selection through
12
+ * the actual gesture rather than by reaching into Timeline's state.
13
+ *
14
+ * `Harness` below is a CONTROLLED host, not a bare `<Timeline>` — it feeds
15
+ * `onProjectChange`/`onOverlayEdit` straight back into the `project` prop,
16
+ * exactly as the real host (`VideoEditor.tsx`) does. Without that, the mid-
17
+ * drag retime would never reach Timeline's own re-render, and the Delete
18
+ * guard (`keyframeUnionTimes(item).includes(selectedKeyframe.t)` — see
19
+ * Timeline.tsx) would check the followed `t` against the STALE, un-retimed
20
+ * project and (correctly, on its own terms) find nothing there — which
21
+ * would look identical to the bug this file exists to catch, for the wrong
22
+ * reason. `Timeline.keyframeDelete.test.tsx`'s own last test hits the same
23
+ * seam with a manual `rerender`; a controlled component is the equivalent
24
+ * for a gesture that retimes mid-test rather than being swapped in whole.
25
+ *
26
+ * Delete removing the point that's still there (not the whole clip, and not
27
+ * a no-op) is the only externally observable proof that `selectedKeyframe`
28
+ * followed the diamond — `pointer-machine.test.ts`'s "selection follows a
29
+ * retimed diamond" tests cover the effect itself, in isolation.
30
+ *
31
+ * Deliberately NOT `fireEvent.pointerDown`/`pointerMove` — this repo's jsdom
32
+ * silently drops `clientX`/`clientY` off synthetic PointerEvents, which would
33
+ * make a drag never leave its press-time point without failing loudly. Raw
34
+ * `MouseEvent`s dispatched straight onto `document` (`TimelineCanvas` listens
35
+ * there for the life of a gesture) are what every other canvas gesture test
36
+ * in this package uses instead.
37
+ */
38
+ import { useState } from 'react'
39
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
40
+ import { act, render, cleanup, fireEvent } from '@testing-library/react'
41
+ import type { Project } from '../../../types'
42
+ import { createPlaybackClock } from '../../playback-clock'
43
+ import Timeline from '../Timeline'
44
+ import { computeTimelineLayout } from '../canvas/draw'
45
+ import { canvasSurface, focusCanvasRoot, installCanvasHarness, timeToClientX } from './_canvasSelect'
46
+
47
+ let uninstall: () => void
48
+
49
+ beforeEach(() => {
50
+ uninstall = installCanvasHarness()
51
+ })
52
+
53
+ afterEach(() => {
54
+ cleanup()
55
+ uninstall()
56
+ })
57
+
58
+ /**
59
+ * clip-0 (0s-2s, video, plain) + o0 (2s-4s, overlay) keyframed on `scale` at
60
+ * item-relative t=0.5 AND t=1.5 — the same fixture shape
61
+ * `Timeline.keyframeDelete.test.tsx` uses, so a diamond drag here lands on
62
+ * familiar ground: two points on one track, neither the union-of-props case.
63
+ */
64
+ function makeProject(): Project {
65
+ return {
66
+ id: 'p1',
67
+ status: 'draft',
68
+ settings: { resolution: [1080, 1920], fps: 30 },
69
+ tracks: [{
70
+ id: 'trk-0',
71
+ items: [
72
+ { id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 2, inPoint: 0, outPoint: 2 },
73
+ {
74
+ id: 'o0', type: 'overlay', start: 2, end: 4,
75
+ keyframes: [
76
+ { prop: 'scale', points: [{ t: 0.5, value: 1 }, { t: 1.5, value: 2 }] },
77
+ ],
78
+ },
79
+ ],
80
+ }],
81
+ } as unknown as Project
82
+ }
83
+
84
+ /** A controlled host: `project`/`selectedIds` live in real React state and
85
+ * are written back on every `onProjectChange`/`onOverlayEdit`/`onSelectIds`,
86
+ * same wiring `VideoEditor.tsx` gives Timeline in the real app. The spy
87
+ * functions record every call for assertions without replacing this
88
+ * feedback loop. */
89
+ function Harness(props: {
90
+ initial: Project
91
+ selectedIds: string[]
92
+ onProjectChange: (p: Project) => void
93
+ onOverlayEdit: (p: Project) => void
94
+ onSelectIds: (ids: string[]) => void
95
+ }) {
96
+ const [clock] = useState(() => createPlaybackClock())
97
+ const [project, setProject] = useState(props.initial)
98
+ const [selectedIds, setSelectedIds] = useState(props.selectedIds)
99
+ return (
100
+ <Timeline
101
+ project={project}
102
+ clock={clock}
103
+ onProjectChange={p => { setProject(p); props.onProjectChange(p) }}
104
+ onOverlayEdit={p => { setProject(p); props.onOverlayEdit(p) }}
105
+ selectedIds={selectedIds}
106
+ onSelectIds={ids => { setSelectedIds(ids); props.onSelectIds(ids) }}
107
+ />
108
+ )
109
+ }
110
+
111
+ function mount(project: Project, selectedIds: string[]) {
112
+ const onProjectChange = vi.fn()
113
+ const onOverlayEdit = vi.fn()
114
+ const onSelectIds = vi.fn()
115
+ const utils = render(
116
+ <Harness
117
+ initial={project}
118
+ selectedIds={selectedIds}
119
+ onProjectChange={onProjectChange}
120
+ onOverlayEdit={onOverlayEdit}
121
+ onSelectIds={onSelectIds}
122
+ />,
123
+ )
124
+ return { onProjectChange, onOverlayEdit, onSelectIds, surface: canvasSurface(utils.container), ...utils }
125
+ }
126
+
127
+ /** CLIENT (x, y) for o0's diamond at item-relative `t` — the same math
128
+ * `Timeline.keyframeDelete.test.tsx`'s own `keyframeClientPoint` uses.
129
+ * Computed once, off the ORIGINAL project: `_canvasSelect.ts`'s viewport
130
+ * helpers fit to `computeDerivedTiming`, and neither this fixture's total
131
+ * duration nor its row layout changes as `o0`'s keyframes move, so the same
132
+ * coordinates stay valid for the whole test. */
133
+ function keyframeClientPoint(project: Project, t: number) {
134
+ const row = computeTimelineLayout(project).rows[0]
135
+ const clientX = timeToClientX(project, 2 + t) // o0.start is 2
136
+ return { clientX, clientY: row.y + row.height - 2 }
137
+ }
138
+
139
+ /** Select a keyframe diamond the honest way: a real double-click. */
140
+ function selectKeyframeAt(surface: HTMLElement, project: Project, t: number) {
141
+ fireEvent.doubleClick(surface, keyframeClientPoint(project, t))
142
+ }
143
+
144
+ function mouseInit(p: { clientX: number; clientY: number }) {
145
+ return { clientX: p.clientX, clientY: p.clientY, button: 0, bubbles: true }
146
+ }
147
+
148
+ /** Drag o0's diamond at item-relative `fromT` by `dxPx` client pixels: press
149
+ * on the surface, two intermediate moves and a release dispatched straight
150
+ * onto `document` — `TimelineCanvas`'s own gesture wiring — mirroring
151
+ * `_canvasSelect.ts`'s `dragCanvasItem` (which cannot itself target a
152
+ * keyframe diamond; `resolveCanvasTarget` only resolves clips/audio/
153
+ * captions). `act` wraps the raw `document.dispatchEvent` calls for the
154
+ * same reason `dragCanvasItem`'s `dispatchOnDocument` does — those two
155
+ * aren't one of RTL's own wrapped fire helpers. */
156
+ function dragKeyframe(surface: HTMLElement, project: Project, fromT: number, dxPx: number) {
157
+ const from = keyframeClientPoint(project, fromT)
158
+ fireEvent.mouseDown(surface, mouseInit(from))
159
+ const mid = { clientX: from.clientX + dxPx / 2, clientY: from.clientY }
160
+ act(() => { document.dispatchEvent(new MouseEvent('mousemove', mouseInit(mid))) })
161
+ const to = { clientX: from.clientX + dxPx, clientY: from.clientY }
162
+ act(() => { document.dispatchEvent(new MouseEvent('mousemove', mouseInit(to))) })
163
+ act(() => { document.dispatchEvent(new MouseEvent('mouseup', mouseInit(to))) })
164
+ }
165
+
166
+ describe('Timeline — keyframe selection follows a retimed diamond', () => {
167
+ it('Delete removes the point the diamond landed on after a drag, not the clip — proving the selection followed', () => {
168
+ const project = makeProject()
169
+ const { surface, container, onProjectChange } = mount(project, ['o0'])
170
+ selectKeyframeAt(surface, project, 0.5)
171
+ focusCanvasRoot(container)
172
+
173
+ // Drag the SELECTED t=0.5 diamond well clear of both its own start and
174
+ // the untouched t=1.5 point, so there is no ambiguity about which point
175
+ // Delete removed.
176
+ dragKeyframe(surface, project, 0.5, 30)
177
+
178
+ fireEvent.keyDown(document.body, { key: 'Delete' })
179
+
180
+ // The clip-delete fallback (`timeline.delete-selection`) would have
181
+ // removed 'o0' outright instead of just a keyframe point — it did not.
182
+ const updated = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
183
+ const items = updated.tracks![0].items
184
+ expect(items.map(i => i.id)).toEqual(['clip-0', 'o0'])
185
+
186
+ const overlay = items.find(i => i.id === 'o0')!
187
+ const scaleTrack = overlay.keyframes!.find(t => t.prop === 'scale')!
188
+ // Exactly one point survives: t=1.5, which the drag never touched. The
189
+ // point the drag moved to is gone — proof Delete's target followed the
190
+ // diamond rather than staying pinned to the vacated t=0.5 (which, with
191
+ // the bug, would have found nothing there and fallen through to
192
+ // deleting the whole clip instead — the assertion above already rules
193
+ // that out, this one pins down which point actually went).
194
+ expect(scaleTrack.points).toHaveLength(1)
195
+ expect(scaleTrack.points[0].t).toBeCloseTo(1.5)
196
+ })
197
+
198
+ it('a second Delete now hits the clip — the followed selection was consumed, not left dangling', () => {
199
+ const project = makeProject()
200
+ const { surface, container, onProjectChange, onSelectIds } = mount(project, ['o0'])
201
+ selectKeyframeAt(surface, project, 0.5)
202
+ focusCanvasRoot(container)
203
+
204
+ dragKeyframe(surface, project, 0.5, 30)
205
+
206
+ fireEvent.keyDown(document.body, { key: 'Delete' }) // removes the followed keyframe
207
+ const afterFirst = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
208
+ expect(afterFirst.tracks![0].items.find(i => i.id === 'o0')).toBeDefined()
209
+
210
+ fireEvent.keyDown(document.body, { key: 'Delete' }) // no keyframe selected anymore
211
+ expect(onSelectIds).toHaveBeenCalledWith([])
212
+ const afterSecond = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
213
+ expect(afterSecond.tracks![0].items.find(i => i.id === 'o0')).toBeUndefined()
214
+ })
215
+ })