@bycrux/editor 0.12.0 → 1.0.2

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 +161 -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,316 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * Pending-import ghosts, at the COMPONENT level — the seam between the host's
4
+ * `PendingDrop[]` and the pixels.
5
+ *
6
+ * `pending-drop.test.ts` proves the painter given an already-resolved
7
+ * `PendingDropBand`. What it cannot reach is the half that lives in
8
+ * `TimelineCanvas`: turning a host's `trackIndex` into a row rectangle
9
+ * (`pendingDropBands`), and repainting the overlay when the prop changes. Both
10
+ * are exactly the join a unit test of either side alone would miss — a ghost
11
+ * resolved against the wrong row, or a correct ghost that never gets painted
12
+ * because nothing marked the layer dirty.
13
+ *
14
+ * The redraw case is the one worth being careful about: the overlay layer is
15
+ * repainted only by the clock, the viewport or a gesture, none of which fire
16
+ * here. So the assertion is deliberately before/after — nothing painted on the
17
+ * first pass, a ghost painted after the prop change — because a test that only
18
+ * looked at the first render would pass with the redraw effect deleted.
19
+ *
20
+ * jsdom has no 2D canvas, so `getContext` hands back a RECORDING proxy, one per
21
+ * canvas element: this surface stacks two (content below, overlay above), and a
22
+ * single shared recorder could not tell which layer a ghost landed on.
23
+ */
24
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
25
+ import { render, act, cleanup } from '@testing-library/react'
26
+ import { createPlaybackClock } from '../../../playback-clock'
27
+ import type { Project, PendingDrop } from '../../../../types'
28
+ import TimelineCanvas from '../TimelineCanvas'
29
+ import { createViewportStore } from '../viewport'
30
+ import { timeToX } from '../viewport'
31
+ import {
32
+ PENDING_DROP_INSET_PX,
33
+ PENDING_DROP_RADIUS_PX,
34
+ RULER_HEIGHT_PX,
35
+ computeTimelineLayout,
36
+ } from '../draw'
37
+
38
+ // ── Recording context, one per canvas element ────────────────────────────
39
+
40
+ interface RecordedCall { method: string; args: unknown[] }
41
+
42
+ interface Recorder {
43
+ calls: RecordedCall[]
44
+ of: (method: string) => RecordedCall[]
45
+ reset: () => void
46
+ }
47
+
48
+ const recorders = new Map<HTMLCanvasElement, Recorder>()
49
+
50
+ function recorderFor(canvas: HTMLCanvasElement) {
51
+ let rec = recorders.get(canvas)
52
+ if (!rec) {
53
+ const calls: RecordedCall[] = []
54
+ rec = {
55
+ calls,
56
+ of: (method: string) => calls.filter(c => c.method === method),
57
+ reset: () => { calls.length = 0 },
58
+ }
59
+ recorders.set(canvas, rec)
60
+ }
61
+ return rec
62
+ }
63
+
64
+ function contextFor(canvas: HTMLCanvasElement) {
65
+ const rec = recorderFor(canvas)
66
+ const props: Record<string, unknown> = {}
67
+ return new Proxy({}, {
68
+ get(_t, prop: string) {
69
+ if (prop in props) return props[prop]
70
+ if (prop === 'createLinearGradient') return () => ({ addColorStop: () => {} })
71
+ return (...args: unknown[]) => { rec.calls.push({ method: prop, args }) }
72
+ },
73
+ set(_t, prop: string, value: unknown) {
74
+ props[prop] = value
75
+ rec.calls.push({ method: `set:${prop}`, args: [value] })
76
+ return true
77
+ },
78
+ })
79
+ }
80
+
81
+ let realGetContext: typeof HTMLCanvasElement.prototype.getContext
82
+ let realGetRect: typeof Element.prototype.getBoundingClientRect
83
+
84
+ beforeEach(() => {
85
+ recorders.clear()
86
+ realGetContext = HTMLCanvasElement.prototype.getContext
87
+ realGetRect = Element.prototype.getBoundingClientRect
88
+ HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement) {
89
+ return contextFor(this)
90
+ } as unknown as typeof HTMLCanvasElement.prototype.getContext
91
+ Element.prototype.getBoundingClientRect = function (this: Element) {
92
+ return { x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 100, width: 1000, height: 100, toJSON: () => ({}) } as DOMRect
93
+ }
94
+ vi.useFakeTimers()
95
+ })
96
+
97
+ afterEach(() => {
98
+ cleanup()
99
+ vi.useRealTimers()
100
+ HTMLCanvasElement.prototype.getContext = realGetContext
101
+ Element.prototype.getBoundingClientRect = realGetRect
102
+ recorders.clear()
103
+ })
104
+
105
+ // Two video tracks, so a `trackIndex` actually chooses between rows that sit at
106
+ // DIFFERENT y — with one row every assertion below would pass by accident.
107
+ const project = {
108
+ id: 'p',
109
+ tracks: [
110
+ { id: 'trk-0', items: [{ id: 'a0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }] },
111
+ { id: 'trk-1', items: [{ id: 'b0', type: 'video', src: 'b.mp4', start: 0, end: 2, inPoint: 0, outPoint: 2, sourceDuration: 20 }] },
112
+ ],
113
+ audio: { tracks: [] },
114
+ } as unknown as Project
115
+
116
+ const TOTAL_DURATION = 20
117
+
118
+ function drop(over: Partial<PendingDrop> = {}): PendingDrop {
119
+ return { id: 'pd-1', atTime: 3, durationSec: 2, trackIndex: 0, label: 'C0042.MP4', ...over }
120
+ }
121
+
122
+ function mount(pendingDrops?: readonly PendingDrop[]) {
123
+ const store = createViewportStore()
124
+ const clock = createPlaybackClock()
125
+
126
+ const props = {
127
+ project,
128
+ clock,
129
+ store,
130
+ totalDuration: TOTAL_DURATION,
131
+ fps: 30,
132
+ selectedIds: [] as string[],
133
+ }
134
+
135
+ const utils = render(<TimelineCanvas {...props} pendingDrops={pendingDrops} />)
136
+ act(() => { vi.advanceTimersByTime(32) })
137
+
138
+ const canvases = utils.container.querySelectorAll('canvas')
139
+ const overlay = recorderFor(canvases[1] as HTMLCanvasElement)
140
+ const content = recorderFor(canvases[0] as HTMLCanvasElement)
141
+
142
+ // Pin the scale so x is readable: x = t × 100. Until this lands the surface
143
+ // sits at its own fit scale (1000px / 20s = 50px/s), and the paint that
144
+ // already happened at THAT scale is still in the recorders — so they are
145
+ // cleared between marking the layers dirty and flushing the frame, leaving
146
+ // exactly one settled paint to assert on.
147
+ act(() => { store.set({ pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }) })
148
+ overlay.reset()
149
+ content.reset()
150
+ act(() => { vi.advanceTimersByTime(32) })
151
+
152
+ return {
153
+ ...utils,
154
+ store,
155
+ // The surface stacks content first, overlay second (see TimelineCanvas's
156
+ // JSX) — ghosts belong to the overlay.
157
+ overlay,
158
+ content,
159
+ setPendingDrops(next?: readonly PendingDrop[]) {
160
+ utils.rerender(<TimelineCanvas {...props} pendingDrops={next} />)
161
+ act(() => { vi.advanceTimersByTime(32) })
162
+ },
163
+ }
164
+ }
165
+
166
+ /** Where the painter should start the ghost's rounded path, derived from the
167
+ * layout the canvas itself draws from — never a hard-coded pixel. The fill
168
+ * path opens at `(x + radius, y)`; see `roundRectPath`. */
169
+ function expectedGhostOrigin(trackIdx: number, atTime: number, store: ReturnType<typeof createViewportStore>) {
170
+ const row = computeTimelineLayout(project).rows.find(r => r.trackIdx === trackIdx)
171
+ if (!row) throw new Error(`no row for trackIdx ${trackIdx}`)
172
+ return [
173
+ timeToX(atTime, store.get()) + PENDING_DROP_RADIUS_PX,
174
+ row.y + PENDING_DROP_INSET_PX,
175
+ ]
176
+ }
177
+
178
+ /** The first path the ghost lays down. `setLineDash` is unique to the ghost on
179
+ * this layer, so its presence is what proves one was drawn at all. */
180
+ function ghostOrigin(overlay: Recorder): unknown[] | null {
181
+ if (overlay.of('setLineDash').length === 0) return null
182
+ return overlay.of('moveTo')[0]?.args ?? null
183
+ }
184
+
185
+ describe('TimelineCanvas — pending-drop ghosts', () => {
186
+ it('resolves a trackIndex naming a real row to THAT row\'s rectangle', () => {
187
+ // trackIdx 1 is drawn ABOVE trackIdx 0 (rows descend from the top), so
188
+ // this y is nothing like the base row's — the two cases can't be confused.
189
+ const { overlay, store } = mount([drop({ trackIndex: 1 })])
190
+ expect(ghostOrigin(overlay)).toEqual(expectedGhostOrigin(1, 3, store))
191
+ })
192
+
193
+ it('resolves the other row to ITS rectangle, at a different y', () => {
194
+ const { overlay, store } = mount([drop({ trackIndex: 0 })])
195
+ expect(ghostOrigin(overlay)).toEqual(expectedGhostOrigin(0, 3, store))
196
+ // Guard against the two expectations having collapsed into one value.
197
+ expect(expectedGhostOrigin(0, 3, store)).not.toEqual(expectedGhostOrigin(1, 3, store))
198
+ })
199
+
200
+ it('falls back to the base video row for trackIndex -1', () => {
201
+ // -1 is "no preference" — the base video row is the LOWEST trackIdx that
202
+ // holds video, which here is trkIdx 0, drawn at the bottom.
203
+ const { overlay, store } = mount([drop({ trackIndex: -1 })])
204
+ expect(ghostOrigin(overlay)).toEqual(expectedGhostOrigin(0, 3, store))
205
+ })
206
+
207
+ it('falls back to the base video row for a trackIndex past the end, without throwing', () => {
208
+ // The host's list is asynchronous: the project can lose a track between the
209
+ // drop and the ghost being drawn, leaving a trackIndex that names nothing.
210
+ expect(() => {
211
+ const { overlay, store } = mount([drop({ trackIndex: 99 })])
212
+ expect(ghostOrigin(overlay)).toEqual(expectedGhostOrigin(0, 3, store))
213
+ }).not.toThrow()
214
+ })
215
+
216
+ it('draws a newTrack ghost on a FRESH row — never the base row, never into the ruler', () => {
217
+ // `trackIndex` here is deliberately a value that names no real row (the
218
+ // sentinel `resolveDropTrackIndex` returns for "mint a new track") — the
219
+ // point of `newTrack` is that the renderer must not need `trackIndex` to
220
+ // resolve this at all.
221
+ const { overlay } = mount([drop({ trackIndex: 2, newTrack: true })])
222
+ const origin = ghostOrigin(overlay)
223
+ expect(origin).not.toBeNull()
224
+ const [, y] = origin as [number, number]
225
+
226
+ // Not the base row (trackIdx 0, at the bottom of the video block) — the
227
+ // exact bug this feature fixes: a new-track ghost sitting on top of
228
+ // whatever footage already occupies the base row.
229
+ const baseRowY = computeTimelineLayout(project).rows.find(r => r.trackIdx === 0)!.y + PENDING_DROP_INSET_PX
230
+ expect(y).not.toEqual(baseRowY)
231
+ // Not even the CURRENT top-of-video-block row (trackIdx 1) — this is a
232
+ // genuinely fresh row, one slot further toward the ruler than any row
233
+ // that exists today.
234
+ const topVideoRowY = computeTimelineLayout(project).rows.find(r => r.trackIdx === 1)!.y + PENDING_DROP_INSET_PX
235
+ expect(y).not.toEqual(topVideoRowY)
236
+ // Never intrudes into the ruler strip.
237
+ expect(y).toBeGreaterThanOrEqual(RULER_HEIGHT_PX)
238
+ // This fixture has no overlay/caption rows above the video block, so
239
+ // there is no room for a full extra row without the surface actually
240
+ // growing by one — the phantom clamps flush against the ruler's bottom
241
+ // edge. Still a row of its own, and still never past the ruler.
242
+ expect(y).toEqual(RULER_HEIGHT_PX + PENDING_DROP_INSET_PX)
243
+ })
244
+
245
+ it('ignores newTrack on a drop that already names a real row', () => {
246
+ // `newTrack` absent (or false) is the ordinary case — unchanged from
247
+ // before this field existed.
248
+ const { overlay, store } = mount([drop({ trackIndex: 1 })])
249
+ expect(ghostOrigin(overlay)).toEqual(expectedGhostOrigin(1, 3, store))
250
+ })
251
+
252
+ it('resolves each drop in a list independently', () => {
253
+ const { overlay, store } = mount([
254
+ drop({ id: 'pd-1', trackIndex: 1, atTime: 3 }),
255
+ drop({ id: 'pd-2', trackIndex: 0, atTime: 6 }),
256
+ ])
257
+ // Each band lays two paths (fill, then the dashed outline inset by half the
258
+ // stroke), so the fill origins are the even entries.
259
+ const origins = overlay.of('moveTo').filter((_, i) => i % 2 === 0).map(c => c.args)
260
+ expect(origins).toEqual([
261
+ expectedGhostOrigin(1, 3, store),
262
+ expectedGhostOrigin(0, 6, store),
263
+ ])
264
+ })
265
+
266
+ it('paints no ghost when pendingDrops is absent or empty', () => {
267
+ for (const value of [undefined, [] as PendingDrop[]]) {
268
+ const { overlay } = mount(value)
269
+ expect(overlay.of('setLineDash')).toHaveLength(0)
270
+ cleanup()
271
+ }
272
+ })
273
+
274
+ // ── The redraw wiring ──
275
+ //
276
+ // Nothing else repaints this layer here: the clock never ticks, the viewport
277
+ // is already settled, no gesture runs. So if the prop change did not mark the
278
+ // overlay dirty, the "after" assertion below would still see an empty
279
+ // recorder — which is exactly what makes this a test of the wiring rather
280
+ // than of the first render.
281
+
282
+ it('repaints the overlay when pendingDrops APPEARS on a mounted surface', () => {
283
+ const { overlay, store, setPendingDrops } = mount()
284
+ expect(ghostOrigin(overlay)).toBeNull()
285
+
286
+ overlay.reset()
287
+ setPendingDrops([drop({ trackIndex: 1 })])
288
+ expect(ghostOrigin(overlay)).toEqual(expectedGhostOrigin(1, 3, store))
289
+ })
290
+
291
+ it('repaints when a ghost MOVES, and the new band is at the new row', () => {
292
+ const { overlay, store, setPendingDrops } = mount([drop({ trackIndex: 1 })])
293
+ expect(ghostOrigin(overlay)).toEqual(expectedGhostOrigin(1, 3, store))
294
+
295
+ overlay.reset()
296
+ setPendingDrops([drop({ trackIndex: 0, atTime: 6 })])
297
+ expect(ghostOrigin(overlay)).toEqual(expectedGhostOrigin(0, 6, store))
298
+ })
299
+
300
+ it('repaints with the ghost GONE when the host retracts it', () => {
301
+ // The retraction is the half that actually matters in production: a ghost
302
+ // that never comes down outlives the import it stood for.
303
+ const { overlay, setPendingDrops } = mount([drop()])
304
+ expect(ghostOrigin(overlay)).not.toBeNull()
305
+
306
+ overlay.reset()
307
+ setPendingDrops([])
308
+ expect(overlay.of('clearRect').length).toBeGreaterThan(0)
309
+ expect(overlay.of('setLineDash')).toHaveLength(0)
310
+ })
311
+
312
+ it('keeps ghosts off the CONTENT layer, which clips and filmstrips own', () => {
313
+ const { content } = mount([drop()])
314
+ expect(content.of('setLineDash')).toHaveLength(0)
315
+ })
316
+ })