@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,351 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * Subcut-regenerate on the CANVAS timeline (the port off the DOM clip rows).
4
+ *
5
+ * The affordance is a Scissors button that toggles Timeline's `subcutClipId`,
6
+ * which in turn opens the host-rendered `renderSubcutRegen` tool. It lived on
7
+ * `VisualTrackRow` and had zero tests; the canvas draws clips as pixels, so
8
+ * the port is an absolutely-positioned HTML button placed by the same layout +
9
+ * viewport math the painter uses (`CanvasClipChrome` in Timeline.tsx).
10
+ *
11
+ * Two things these tests exist to pin down, because both were lost or
12
+ * simplified in earlier attempts at this port:
13
+ *
14
+ * 1. The gate has FOUR independent conditions — selected, host-enabled,
15
+ * generation provenance present, and a 3-SECOND MINIMUM. Each gets its own
16
+ * test (and the duration gets its boundary tested both ways) so dropping
17
+ * any one of them fails here rather than shipping.
18
+ * 2. "Queued" is a BADGE, not a disable. A queued clip's button stays
19
+ * clickable, and the badge's own visibility does not depend on the button's
20
+ * gate at all.
21
+ *
22
+ * Plus the hazard the structure exists to avoid: `TimelineCanvas` binds
23
+ * `mousedown` on its own container and a mousedown there STARTS A GESTURE, so
24
+ * a button nested inside it would scrub or drag underneath its own click. The
25
+ * chrome is rendered as a SIBLING of the surface instead — the last test here
26
+ * is what proves the canvas never sees the press.
27
+ */
28
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
29
+ import { render, cleanup, fireEvent, screen, act } from '@testing-library/react'
30
+ import type { Project } from '../../../types'
31
+ import type { VisualItem } from '../../../schema'
32
+ import { createPlaybackClock } from '../../playback-clock'
33
+ import Timeline, { CLIP_CHROME_RIGHT_PAD_PX } from '../Timeline'
34
+ import { computeDerivedTiming } from '../timeline-model'
35
+ import { computeTimelineLayout } from '../canvas/draw'
36
+ import { ZOOM_BUTTON_FACTOR, timeToX, zoomAtPivot } from '../canvas/viewport'
37
+ import {
38
+ SURFACE_WIDTH,
39
+ canvasItemPoint,
40
+ canvasSurface,
41
+ canvasViewport,
42
+ installCanvasHarness,
43
+ } from './_canvasSelect'
44
+
45
+ let uninstallCanvasHarness: () => void
46
+ beforeEach(() => { uninstallCanvasHarness = installCanvasHarness() })
47
+ afterEach(() => {
48
+ cleanup()
49
+ uninstallCanvasHarness()
50
+ })
51
+
52
+ /** A 10s ai_video clip on track 0, carrying the frozen `generation` provenance
53
+ * the gate reads. Track 0 on purpose: Timeline's `renderSubcutRegen` guard
54
+ * resolves `subcutClipId` against the first track, so a fixture anywhere else
55
+ * would set the state and render nothing. */
56
+ function makeProject(overrides: Partial<VisualItem> = {}): Project {
57
+ return {
58
+ id: 'p1',
59
+ status: 'draft',
60
+ settings: { resolution: [1080, 1920], fps: 30 },
61
+ tracks: [{
62
+ id: 'trk-0',
63
+ items: [{
64
+ id: 'clip-0', type: 'video', src: 'a.mp4',
65
+ start: 0, end: 10, inPoint: 0, outPoint: 10,
66
+ generation: { sceneId: 'sc-1', prompt: 'a wide shot', provider: 'kling' },
67
+ ...overrides,
68
+ }],
69
+ }],
70
+ } as unknown as Project
71
+ }
72
+
73
+ interface MountOptions {
74
+ selectedIds?: string[]
75
+ regenEnabled?: boolean
76
+ isClipQueued?: (id: string) => boolean
77
+ withSubcutTool?: boolean
78
+ }
79
+
80
+ function mount(project: Project, opts: MountOptions = {}) {
81
+ const clock = createPlaybackClock(0)
82
+ const onSelectIds = vi.fn()
83
+ const renderSubcutRegen = vi.fn(({ clipId }: { clipId: string }) => (
84
+ <div data-testid="subcut-tool">{clipId}</div>
85
+ ))
86
+ const utils = render(
87
+ <Timeline
88
+ project={project}
89
+ clock={clock}
90
+ selectedIds={opts.selectedIds ?? ['clip-0']}
91
+ onSelectIds={onSelectIds}
92
+ regenEnabled={opts.regenEnabled ?? true}
93
+ isClipQueued={opts.isClipQueued}
94
+ renderSubcutRegen={opts.withSubcutTool === false ? undefined : renderSubcutRegen}
95
+ />,
96
+ )
97
+ return { clock, onSelectIds, renderSubcutRegen, ...utils }
98
+ }
99
+
100
+ /** The button, by the accessible name it carries in both the DOM and canvas
101
+ * implementations. */
102
+ function subcutButton(): HTMLElement | null {
103
+ return screen.queryByRole('button', { name: 'Subcut regenerate' })
104
+ }
105
+
106
+ describe('Timeline — subcut regenerate on the canvas timeline', () => {
107
+ // ── The gate, one condition at a time ──────────────────────────────────
108
+
109
+ it('shows the button when the clip is selected, regen is enabled, it has generation provenance, and it is at least 3s', () => {
110
+ mount(makeProject())
111
+ expect(subcutButton()).toBeInTheDocument()
112
+ })
113
+
114
+ it('hides it when the clip is not selected', () => {
115
+ mount(makeProject(), { selectedIds: [] })
116
+ expect(subcutButton()).not.toBeInTheDocument()
117
+ })
118
+
119
+ it('hides it when the host has not enabled regeneration', () => {
120
+ mount(makeProject(), { regenEnabled: false })
121
+ expect(subcutButton()).not.toBeInTheDocument()
122
+ })
123
+
124
+ it('hides it on a clip with no generation provenance', () => {
125
+ mount(makeProject({ generation: undefined }))
126
+ expect(subcutButton()).not.toBeInTheDocument()
127
+ })
128
+
129
+ // The 3s floor is a real product rule (there is nothing to sub-cut in a
130
+ // shorter clip), so both sides of the boundary are pinned: a simplification
131
+ // that dropped it, or one that used `> 3` instead of `>= 3`, fails here.
132
+ it('hides it just under the 3s floor', () => {
133
+ mount(makeProject({ end: 2.9, outPoint: 2.9 }))
134
+ expect(subcutButton()).not.toBeInTheDocument()
135
+ })
136
+
137
+ it('shows it at exactly 3s', () => {
138
+ mount(makeProject({ end: 3, outPoint: 3 }))
139
+ expect(subcutButton()).toBeInTheDocument()
140
+ })
141
+
142
+ // ── The trigger ────────────────────────────────────────────────────────
143
+
144
+ it('clicking it opens the host subcut tool for that clip', () => {
145
+ const { renderSubcutRegen } = mount(makeProject())
146
+ expect(screen.queryByTestId('subcut-tool')).not.toBeInTheDocument()
147
+
148
+ fireEvent.click(subcutButton()!)
149
+
150
+ expect(renderSubcutRegen).toHaveBeenCalledWith(expect.objectContaining({ clipId: 'clip-0' }))
151
+ expect(screen.getByTestId('subcut-tool')).toHaveTextContent('clip-0')
152
+ })
153
+
154
+ it('clicking it again toggles the tool back off', () => {
155
+ mount(makeProject())
156
+ fireEvent.click(subcutButton()!)
157
+ expect(screen.getByTestId('subcut-tool')).toBeInTheDocument()
158
+
159
+ fireEvent.click(subcutButton()!)
160
+ expect(screen.queryByTestId('subcut-tool')).not.toBeInTheDocument()
161
+ })
162
+
163
+ // ── Queued is a badge, not a disable ───────────────────────────────────
164
+
165
+ it('renders the "queued" badge and leaves the button clickable while queued', () => {
166
+ mount(makeProject(), { isClipQueued: id => id === 'clip-0' })
167
+
168
+ expect(screen.getByText('queued')).toBeInTheDocument()
169
+ const button = subcutButton()!
170
+ expect(button).not.toBeDisabled()
171
+
172
+ // Still opens the tool — a queued clip can be re-opened and re-submitted.
173
+ fireEvent.click(button)
174
+ expect(screen.getByTestId('subcut-tool')).toHaveTextContent('clip-0')
175
+ })
176
+
177
+ it('does not render the badge for a clip the host does not report as queued', () => {
178
+ mount(makeProject(), { isClipQueued: () => false })
179
+ expect(screen.queryByText('queued')).not.toBeInTheDocument()
180
+ })
181
+
182
+ // The badge answers "is this clip in the regen queue?", which is true (and
183
+ // worth showing) whether or not the clip happens to be selected. Its gate is
184
+ // independent of the button's on the DOM rows; keep it that way.
185
+ it('renders the badge independently of the button gate — unselected, regen disabled', () => {
186
+ mount(makeProject(), { selectedIds: [], regenEnabled: false, isClipQueued: () => true })
187
+ expect(screen.getByText('queued')).toBeInTheDocument()
188
+ expect(subcutButton()).not.toBeInTheDocument()
189
+ })
190
+
191
+ // ── Stays pinned to its clip ───────────────────────────────────────────
192
+
193
+ // The chrome is a DOM node over a surface that PANS AND RESCALES without
194
+ // re-rendering React (the canvas redraws from an external viewport store).
195
+ // Timeline's existing `useViewportValue` subscription is what re-lays this
196
+ // out; without it the button detaches from its clip on the first zoom.
197
+ it('re-pins the button to the clip after a zoom change', () => {
198
+ const project = makeProject()
199
+ mount(project)
200
+
201
+ const rightBefore = parseFloat(subcutButton()!.parentElement!.style.right)
202
+ // Sanity: the fitted position is the clip's own right edge, inset.
203
+ const fitted = canvasViewport(project)
204
+ expect(rightBefore).toBeCloseTo(
205
+ SURFACE_WIDTH - timeToX(10, fitted) + CLIP_CHROME_RIGHT_PAD_PX, 4,
206
+ )
207
+
208
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
209
+
210
+ // Recomputed through the production math the zoom button itself uses, so
211
+ // this asserts "follows the viewport", not a hardcoded pixel.
212
+ const { totalDuration } = computeDerivedTiming(project)
213
+ const zoomed = zoomAtPivot(fitted, ZOOM_BUTTON_FACTOR, SURFACE_WIDTH / 2, totalDuration)
214
+ const rightAfter = parseFloat(subcutButton()!.parentElement!.style.right)
215
+
216
+ expect(zoomed.pxPerSecond).toBeGreaterThan(fitted.pxPerSecond) // the zoom really moved
217
+ expect(rightAfter).not.toBeCloseTo(rightBefore, 2)
218
+ expect(rightAfter).toBeCloseTo(
219
+ Math.max(CLIP_CHROME_RIGHT_PAD_PX, zoomed.widthPx - timeToX(10, zoomed) + CLIP_CHROME_RIGHT_PAD_PX), 4,
220
+ )
221
+ })
222
+
223
+ // The horizontal pin above only proves `right`/`row.y` line up on ONE axis.
224
+ // `top`/`height` track `row.y`/`row.height` independently — this is the axis
225
+ // that breaks if `layout.rows` ordering ever changes, or if the positioning-
226
+ // context assumption (the chrome's parent IS the surface's box) stops
227
+ // holding — so it gets its own pin against the same production layout the
228
+ // painter draws from.
229
+ it('pins the chrome vertically to its row', () => {
230
+ const project = makeProject()
231
+ mount(project)
232
+
233
+ const row = computeTimelineLayout(project).rows[0]
234
+ const chrome = subcutButton()!.parentElement!
235
+ expect(parseFloat(chrome.style.top)).toBeCloseTo(row.y, 4)
236
+ expect(parseFloat(chrome.style.height)).toBeCloseTo(row.height, 4)
237
+ })
238
+
239
+ // The right-anchored box also carries a LEFT bound (`Math.max(0, x0)`) so
240
+ // `overflow-hidden` clips it to the clip's own span — without it, a narrow
241
+ // clip's right-anchored chrome overhangs leftward past the clip's start and
242
+ // reads as the neighbouring clip's badge (see the comment on `left` in
243
+ // Timeline.tsx). Zooming in centered on this clip's own middle pushes its
244
+ // start well past the surface's left edge while its end stays on-screen,
245
+ // which is exactly the shape that exercises the clamp.
246
+ it('clamps the left edge to the surface when the clip starts off-screen to the left', () => {
247
+ const project = makeProject()
248
+ mount(project)
249
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
250
+
251
+ // Sanity: production math agrees the clip's UN-clamped left edge is
252
+ // actually negative here — otherwise this assertion would pass trivially.
253
+ const fitted = canvasViewport(project)
254
+ const { totalDuration } = computeDerivedTiming(project)
255
+ const zoomed = zoomAtPivot(fitted, ZOOM_BUTTON_FACTOR, SURFACE_WIDTH / 2, totalDuration)
256
+ expect(timeToX(0, zoomed)).toBeLessThan(0)
257
+
258
+ const chrome = subcutButton()!.parentElement!
259
+ expect(parseFloat(chrome.style.left)).toBe(0)
260
+ })
261
+
262
+ // A clip panned entirely off the surface (`x1 <= 0 || x0 >= widthPx`) should
263
+ // render no chrome at all — not a box clamped to an edge, which is the
264
+ // previous test's case. A second clip, far enough away that zooming in on
265
+ // the first one's own middle pushes the FIRST clip's whole span behind the
266
+ // surface's left edge, is what gives this a real subject: a single isolated
267
+ // clip always straddles a pivot centered on itself, however far you zoom.
268
+ it('does not render chrome for a clip scrolled entirely off the surface', () => {
269
+ const project: Project = {
270
+ id: 'p1',
271
+ status: 'draft',
272
+ settings: { resolution: [1080, 1920], fps: 30 },
273
+ tracks: [{
274
+ id: 'trk-0',
275
+ items: [
276
+ {
277
+ id: 'clip-0', type: 'video', src: 'a.mp4',
278
+ start: 0, end: 10, inPoint: 0, outPoint: 10,
279
+ generation: { sceneId: 'sc-1', prompt: 'a wide shot', provider: 'kling' },
280
+ },
281
+ {
282
+ id: 'clip-1', type: 'video', src: 'b.mp4',
283
+ start: 500, end: 510, inPoint: 0, outPoint: 10,
284
+ },
285
+ ],
286
+ }],
287
+ } as unknown as Project
288
+
289
+ mount(project, { selectedIds: ['clip-0'] })
290
+ expect(subcutButton()).toBeInTheDocument() // on-screen before any zoom
291
+
292
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
293
+
294
+ // Sanity: production math agrees clip-0's span is now entirely left of
295
+ // the surface — this is what gives the cull a real subject to remove.
296
+ const fitted = canvasViewport(project)
297
+ const { totalDuration } = computeDerivedTiming(project)
298
+ const zoomed = zoomAtPivot(fitted, ZOOM_BUTTON_FACTOR, SURFACE_WIDTH / 2, totalDuration)
299
+ expect(timeToX(10, zoomed)).toBeLessThanOrEqual(0)
300
+
301
+ expect(subcutButton()).not.toBeInTheDocument()
302
+ })
303
+
304
+ // ── The event hazard ───────────────────────────────────────────────────
305
+
306
+ /**
307
+ * `TimelineCanvas` binds `mousedown` on its container element and treats it
308
+ * as the start of a gesture: it focuses the surface, and it attaches
309
+ * document-level `mousemove`/`mouseup` for the life of the press — the
310
+ * `mouseup` being what selects a clip. So "did a gesture start?" is
311
+ * observable three ways, and this asserts all three.
312
+ *
313
+ * Note a React `onMouseDown={e => e.stopPropagation()}` on the button could
314
+ * NOT have prevented this: React delegates synthetic events to its root
315
+ * container, which sits ABOVE the canvas' own native listener on the
316
+ * propagation path, so the canvas would already have run. The fix is
317
+ * structural — the chrome is not a descendant of `[data-timeline-canvas]` —
318
+ * and this test is what holds that structure in place.
319
+ */
320
+ it('a mousedown on the button does not start a canvas gesture', () => {
321
+ const project = makeProject()
322
+ const { container, clock, onSelectIds } = mount(project)
323
+ const point = canvasItemPoint(project, { id: 'clip-0' })
324
+ const init = { ...point, button: 0, bubbles: true }
325
+
326
+ fireEvent.mouseDown(subcutButton()!, init)
327
+ act(() => { document.dispatchEvent(new MouseEvent('mouseup', init)) })
328
+
329
+ expect(document.activeElement).not.toBe(canvasSurface(container))
330
+ expect(onSelectIds).not.toHaveBeenCalled()
331
+ expect(clock.get()).toBe(0)
332
+ })
333
+
334
+ // Positive control for the test above: the very same press/release, with the
335
+ // very same props and the very same coordinates, dispatched on the SURFACE
336
+ // instead of on the button. It focuses the surface and it reaches the
337
+ // machine's select. Without this the assertion above would also pass if the
338
+ // coordinates were simply dead, or if the canvas had stopped listening.
339
+ it('the same press on the surface itself does start a gesture', () => {
340
+ const project = makeProject()
341
+ const { container, onSelectIds } = mount(project)
342
+ const point = canvasItemPoint(project, { id: 'clip-0' })
343
+ const init = { ...point, button: 0, bubbles: true }
344
+
345
+ fireEvent.mouseDown(canvasSurface(container), init)
346
+ act(() => { document.dispatchEvent(new MouseEvent('mouseup', init)) })
347
+
348
+ expect(document.activeElement).toBe(canvasSurface(container))
349
+ expect(onSelectIds).toHaveBeenCalledTimes(1)
350
+ })
351
+ })