@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,114 @@
1
+ /**
2
+ * SP4 T7 — the Preparing placeholder's show-delay and the debug HUD's gate.
3
+ *
4
+ * `EngineSurface`'s canvas plumbing (attach identity, z-index, transform
5
+ * container placement) is T6's concern and already covered by
6
+ * `PreviewPlayer.engine.test.tsx`. What's new here: the placeholder must
7
+ * survive a sustained `preparing` but never flash for a one-tick transitional
8
+ * one, and the HUD must render only when `debugHud` is truthy.
9
+ */
10
+ import { describe, it, expect, vi, afterEach } from 'vitest'
11
+ import { render, screen, act, cleanup } from '@testing-library/react'
12
+ import EngineSurface from '../EngineSurface'
13
+ import type { EngineStats } from '../../../engine'
14
+
15
+ afterEach(() => {
16
+ cleanup()
17
+ vi.useRealTimers()
18
+ })
19
+
20
+ const noStats = () => null as EngineStats | null
21
+
22
+ describe('EngineSurface Preparing placeholder', () => {
23
+ it('does not show for `video`, even well past the show-delay', () => {
24
+ vi.useFakeTimers()
25
+ render(<EngineSurface attach={() => {}} picture="video" getStats={noStats} />)
26
+ act(() => { vi.advanceTimersByTime(5_000) })
27
+ expect(screen.queryByText('Preparing preview…')).toBeNull()
28
+ })
29
+
30
+ it('does not show for `black` or `opaque`', () => {
31
+ vi.useFakeTimers()
32
+ const { rerender } = render(<EngineSurface attach={() => {}} picture="black" getStats={noStats} />)
33
+ act(() => { vi.advanceTimersByTime(5_000) })
34
+ expect(screen.queryByText('Preparing preview…')).toBeNull()
35
+
36
+ rerender(<EngineSurface attach={() => {}} picture="opaque" getStats={noStats} />)
37
+ act(() => { vi.advanceTimersByTime(5_000) })
38
+ expect(screen.queryByText('Preparing preview…')).toBeNull()
39
+ })
40
+
41
+ it('shows once `preparing` has been sustained past the show-delay', () => {
42
+ vi.useFakeTimers()
43
+ render(<EngineSurface attach={() => {}} picture="preparing" reason="no editing proxy yet" getStats={noStats} />)
44
+
45
+ // Not yet — still inside the delay window.
46
+ act(() => { vi.advanceTimersByTime(100) })
47
+ expect(screen.queryByText('Preparing preview…')).toBeNull()
48
+
49
+ act(() => { vi.advanceTimersByTime(200) })
50
+ expect(screen.getByText('Preparing preview…')).toBeTruthy()
51
+ expect(screen.getByText('Preparing preview…').closest('.montaj-engine-preparing')?.getAttribute('title')).toBe(
52
+ 'no editing proxy yet',
53
+ )
54
+ })
55
+
56
+ it('never shows for a one-tick transitional `preparing` inside a boundary swap', () => {
57
+ vi.useFakeTimers()
58
+ const { rerender } = render(<EngineSurface attach={() => {}} picture="video" getStats={noStats} />)
59
+
60
+ rerender(<EngineSurface attach={() => {}} picture="preparing" getStats={noStats} />)
61
+ act(() => { vi.advanceTimersByTime(50) })
62
+ // Resolves before the delay elapses — the very next clip's session landed.
63
+ rerender(<EngineSurface attach={() => {}} picture="video" getStats={noStats} />)
64
+ act(() => { vi.advanceTimersByTime(5_000) })
65
+
66
+ expect(screen.queryByText('Preparing preview…')).toBeNull()
67
+ })
68
+
69
+ it('hides immediately when `preparing` resolves after already being shown', () => {
70
+ vi.useFakeTimers()
71
+ const { rerender } = render(<EngineSurface attach={() => {}} picture="preparing" getStats={noStats} />)
72
+ act(() => { vi.advanceTimersByTime(300) })
73
+ expect(screen.getByText('Preparing preview…')).toBeTruthy()
74
+
75
+ rerender(<EngineSurface attach={() => {}} picture="video" getStats={noStats} />)
76
+ expect(screen.queryByText('Preparing preview…')).toBeNull()
77
+ })
78
+ })
79
+
80
+ describe('EngineSurface debug HUD gate', () => {
81
+ it('renders nothing when `debugHud` is absent or false', () => {
82
+ const { container, rerender } = render(
83
+ <EngineSurface attach={() => {}} picture="video" getStats={noStats} />,
84
+ )
85
+ expect(container.querySelector('.montaj-engine-debug-hud')).toBeNull()
86
+
87
+ rerender(<EngineSurface attach={() => {}} picture="video" debugHud={false} getStats={noStats} />)
88
+ expect(container.querySelector('.montaj-engine-debug-hud')).toBeNull()
89
+ })
90
+
91
+ it('renders fps/dropped/buffered/clock when `debugHud` is true', () => {
92
+ const stats: EngineStats = { fps: 29.97, dropped: 3, buffered: 5, clock: 'audio' }
93
+ const { container } = render(
94
+ <EngineSurface attach={() => {}} picture="video" debugHud getStats={() => stats} />,
95
+ )
96
+ const hud = container.querySelector('.montaj-engine-debug-hud')
97
+ expect(hud).not.toBeNull()
98
+ expect(hud!.textContent).toContain('fps 30.0')
99
+ expect(hud!.textContent).toContain('dropped 3')
100
+ expect(hud!.textContent).toContain('buffered 5')
101
+ expect(hud!.textContent).toContain('clock audio')
102
+ })
103
+
104
+ it('renders regardless of the Preparing placeholder', () => {
105
+ vi.useFakeTimers()
106
+ const stats: EngineStats = { fps: 0, dropped: 0, buffered: 0, clock: 'fallback' }
107
+ const { container } = render(
108
+ <EngineSurface attach={() => {}} picture="preparing" debugHud getStats={() => stats} />,
109
+ )
110
+ act(() => { vi.advanceTimersByTime(300) })
111
+ expect(container.querySelector('.montaj-engine-debug-hud')).not.toBeNull()
112
+ expect(container.querySelector('.montaj-engine-preparing')).not.toBeNull()
113
+ })
114
+ })
@@ -97,8 +97,10 @@ describe('OverlayItemsLayer — double-click to edit overlay', () => {
97
97
  const wrapper = container.querySelector('div') as HTMLDivElement
98
98
  fireEvent.dblClick(wrapper)
99
99
 
100
- // The dialog is owned by VideoEditor now: the layer neither inline-edits nor
101
- // renders the modal or a floating pencil itself.
100
+ // Prop editing lives in the right panel's Content tab now — there is no
101
+ // dialog anywhere, so the layer has nothing to render for it. The textbox
102
+ // assertion is the load-bearing one (no INLINE editor in the layer); the
103
+ // two below are belt-and-braces against a modal being reintroduced here.
102
104
  expect(screen.queryByRole('textbox')).toBeNull()
103
105
  expect(screen.queryByRole('heading', { name: 'Edit overlay' })).toBeNull()
104
106
  expect(screen.queryByRole('button', { name: 'Edit overlay props' })).toBeNull()
@@ -0,0 +1,363 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { render } from '@testing-library/react'
3
+ import { geometryAt, geometryFor } from '@bycrux/timeline-core'
4
+ import type { EditorProject, VisualItem } from '../../../schema'
5
+ import type { OverlayFactory } from '../../../types'
6
+ import type { OverlayChanges } from '../useDragOverlay'
7
+ import OverlayItemsLayer from '../OverlayItemsLayer'
8
+
9
+ // T1.2 (SP9b Phase 1) — the preview must sample curve-aware geometry through
10
+ // @bycrux/timeline-core's `geometryAt` for keyframed OVERLAYS, and must NOT
11
+ // animate image/video CLIPS even when they carry a `keyframes` field: the
12
+ // renderer composites clips through `geometryFor` directly (encode-segment.js
13
+ // :305 and :375) with no per-frame browser step to bake a moving transform
14
+ // into, so animating a clip here would be a preview/render divergence — see
15
+ // the comment at OverlayItemsLayer.tsx's interactive-tracks geometry call
16
+ // site. No easing/interpolation math is duplicated in this file — every
17
+ // expected value below is produced by calling geometryAt/geometryFor
18
+ // directly, the same functions the component itself calls.
19
+
20
+ const emptySnap = { x: false, y: false, left: false, right: false, top: false, bottom: false }
21
+
22
+ function makeProject(): EditorProject {
23
+ return {
24
+ id: 'p',
25
+ status: 'draft',
26
+ settings: { resolution: [1080, 1920], fps: 30 },
27
+ tracks: [[]],
28
+ } as unknown as EditorProject
29
+ }
30
+
31
+ function renderLayer(item: VisualItem, opts: {
32
+ currentTime: number
33
+ selected?: boolean
34
+ liveOffset?: { id: string; x: number; y: number } | null
35
+ liveScale?: { id: string; scale: number; scaleX: number; scaleY: number } | null
36
+ liveRotation?: { id: string; rotation: number } | null
37
+ onOverlayChange?: (id: string, changes: OverlayChanges) => void
38
+ }) {
39
+ const utils = render(
40
+ <OverlayItemsLayer
41
+ project={makeProject()}
42
+ currentTime={opts.currentTime}
43
+ isPlaying={false}
44
+ isCanvasProject={false}
45
+ overlayTracks={[[item]]}
46
+ tracks0NonVideo={[]}
47
+ renderScale={0.2}
48
+ selectedOverlayId={opts.selected ? item.id : undefined}
49
+ containerRef={{ current: document.createElement('div') }}
50
+ dragState={null}
51
+ setDragState={vi.fn()}
52
+ liveOffset={opts.liveOffset ?? null}
53
+ liveScale={opts.liveScale ?? null}
54
+ liveRotation={opts.liveRotation ?? null}
55
+ snapGuides={emptySnap}
56
+ snapRotation={null}
57
+ onOverlayChange={opts.onOverlayChange}
58
+ compileOverlay={vi.fn(async (): Promise<OverlayFactory> => () => null)}
59
+ fileUrl={(pth: string) => pth}
60
+ />,
61
+ )
62
+ // The item's wrapper is the outermost div — see OverlayItemsLayer.edit.test.tsx.
63
+ const wrapper = utils.container.querySelector('div') as HTMLDivElement
64
+ return { ...utils, wrapper }
65
+ }
66
+
67
+ beforeEach(() => {
68
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
69
+ vi.spyOn(console, 'error').mockImplementation(() => {})
70
+ })
71
+ afterEach(() => vi.restoreAllMocks())
72
+
73
+ // start=5; offsetX animates 0 -> 100 and opacity animates 1 -> 0 across
74
+ // item-relative t=[0,10]. Chosen so that treating absolute `currentTime` as
75
+ // localT (the bug this file guards against) produces a clearly different,
76
+ // wrong number at currentTime=5 than the correct item-relative sample.
77
+ function keyframedOverlay(over: Partial<VisualItem> = {}): VisualItem {
78
+ return {
79
+ id: 'kf-overlay',
80
+ type: 'overlay',
81
+ src: 'o.jsx',
82
+ start: 5,
83
+ end: 15,
84
+ props: {},
85
+ keyframes: [
86
+ { prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] },
87
+ { prop: 'opacity', points: [{ t: 0, value: 1 }, { t: 10, value: 0 }] },
88
+ ],
89
+ ...over,
90
+ } as VisualItem
91
+ }
92
+
93
+ // PARITY-CRITICAL, KEEP IN SYNC WITH render/test/overlay-transform-parity
94
+ // .test.mjs's `previewStyle` (verbatim copy of OverlayItemsLayer.tsx's own
95
+ // `wrapperStyle` template — that file carries a matching comment pointing
96
+ // back here). That suite proves the RENDER bake matches this exact literal
97
+ // template, but it cannot import this .tsx component into its plain
98
+ // node:test runner, so it hand-transcribes the template instead of reading
99
+ // it from the real component. The test below is what closes that gap: it
100
+ // pins the SAME template against what OverlayItemsLayer ACTUALLY renders, so
101
+ // a drift in the component's real template fails THIS test even though the
102
+ // render-side suite has no way to see it.
103
+ //
104
+ // `scale(sx, sy)` is a strict superset of the old `scale(s)`: on an item with
105
+ // no scaleX/scaleY the resolver hands back both axes equal to `scale`, so the
106
+ // two arguments are the same number and the box is unchanged. The render side
107
+ // is migrating onto this two-argument form under its own slice and still
108
+ // transcribes the one-argument form for now — a known, tracked gap.
109
+ function previewStyle(g: ReturnType<typeof geometryAt>) {
110
+ return {
111
+ transform: `translate(${g.offsetX}%, ${g.offsetY}%) rotate(${g.rotation}deg) scale(${g.scaleX}, ${g.scaleY})`,
112
+ transformOrigin: 'center center',
113
+ opacity: g.opacity,
114
+ }
115
+ }
116
+
117
+ describe('OverlayItemsLayer — keyframed geometry (T1.2)', () => {
118
+ it('pins the REAL rendered wrapper style against the same literal template the render-side parity test uses', () => {
119
+ const item = keyframedOverlay()
120
+ const g = geometryAt(item, 'overlay', 10 - item.start) // currentTime=10 -> localT=5, mid-curve
121
+ const want = previewStyle(g)
122
+
123
+ const { wrapper } = renderLayer(item, { currentTime: 10 })
124
+
125
+ expect(wrapper.style.transform).toBe(want.transform)
126
+ expect(wrapper.style.transformOrigin).toBe(want.transformOrigin)
127
+ expect(wrapper.style.opacity).toBe(String(want.opacity))
128
+ })
129
+
130
+ it('samples different transform/opacity at two currentTime values, matching geometryAt', () => {
131
+ const item = keyframedOverlay()
132
+
133
+ const at5 = renderLayer(item, { currentTime: 5 })
134
+ const g5 = geometryAt(item, 'overlay', 5 - item.start)
135
+ expect(at5.wrapper.style.transform).toContain(`translate(${g5.offsetX}%,`)
136
+ expect(at5.wrapper.style.opacity).toBe(String(g5.opacity))
137
+ at5.unmount()
138
+
139
+ const at10 = renderLayer(item, { currentTime: 10 })
140
+ const g10 = geometryAt(item, 'overlay', 10 - item.start)
141
+ expect(at10.wrapper.style.transform).toContain(`translate(${g10.offsetX}%,`)
142
+ expect(at10.wrapper.style.opacity).toBe(String(g10.opacity))
143
+ at10.unmount()
144
+
145
+ // Sanity: the two samples actually differ, so this test cannot pass by
146
+ // accident even if geometryAt were never wired up at all.
147
+ expect(g5.offsetX).not.toBe(g10.offsetX)
148
+ expect(g5.opacity).not.toBe(g10.opacity)
149
+ })
150
+
151
+ it('uses ITEM-RELATIVE time, not absolute project time', () => {
152
+ // item.start = 5, keyframe at localT=0 has offsetX=0. At currentTime=5
153
+ // the correct localT is 0, so offsetX must be exactly 0. A wiring bug
154
+ // that passed absolute `currentTime` as localT would land at localT=5 —
155
+ // halfway between the t=0 (value 0) and t=10 (value 100) keyframes —
156
+ // producing offsetX=50 instead. The two outcomes are far enough apart
157
+ // that this cannot pass by luck.
158
+ const item = keyframedOverlay()
159
+ const { wrapper } = renderLayer(item, { currentTime: 5 })
160
+
161
+ expect(wrapper.style.transform).toContain('translate(0%,')
162
+ expect(wrapper.style.transform).not.toContain('translate(50%,')
163
+ })
164
+
165
+ it('a keyframe-less overlay renders identically at every currentTime (no regression)', () => {
166
+ const item = {
167
+ id: 'static-overlay', type: 'overlay', src: 'o.jsx', start: 0, end: 10,
168
+ offsetX: 12, offsetY: -8, scale: 1.3, rotation: 15, opacity: 0.6, props: {},
169
+ } as VisualItem
170
+
171
+ const at2 = renderLayer(item, { currentTime: 2 })
172
+ const transformAt2 = at2.wrapper.style.transform
173
+ const opacityAt2 = at2.wrapper.style.opacity
174
+ at2.unmount()
175
+
176
+ const at8 = renderLayer(item, { currentTime: 8 })
177
+ expect(at8.wrapper.style.transform).toBe(transformAt2)
178
+ expect(at8.wrapper.style.opacity).toBe(opacityAt2)
179
+ })
180
+
181
+ it('ANIMATES an image clip carrying keyframes (SP9d)', () => {
182
+ // The clip/overlay asymmetry is gone for geometry: encode-segment.js now
183
+ // compiles a clip's curves into ffmpeg expressions, so motion shown here is
184
+ // motion the export reproduces.
185
+ const item = {
186
+ id: 'kf-image', type: 'image', src: 'img.png', start: 5, end: 15,
187
+ offsetX: 42, opacity: 0.9,
188
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
189
+ } as VisualItem
190
+
191
+ const at5 = renderLayer(item, { currentTime: 5 })
192
+ const t5 = at5.wrapper.style.transform
193
+ at5.unmount()
194
+ const at10 = renderLayer(item, { currentTime: 10 })
195
+ const t10 = at10.wrapper.style.transform
196
+
197
+ // localT 0 → 0%, localT 5 → 50%. The static 42 is NOT what either shows.
198
+ expect(t5).toContain('translate(0%,')
199
+ expect(t10).toContain('translate(50%,')
200
+ expect(t10).not.toBe(t5)
201
+ })
202
+
203
+ it('ANIMATES a video clip carrying keyframes (SP9d)', () => {
204
+ const item = {
205
+ id: 'kf-video', type: 'video', src: 'vid.mp4', start: 5, end: 15,
206
+ offsetX: 7, inPoint: 0,
207
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
208
+ } as VisualItem
209
+
210
+ const at5 = renderLayer(item, { currentTime: 5 })
211
+ const t5 = at5.wrapper.style.transform
212
+ at5.unmount()
213
+ const at10 = renderLayer(item, { currentTime: 10 })
214
+ expect(at10.wrapper.style.transform).not.toBe(t5)
215
+ expect(at10.wrapper.style.transform).toContain('translate(50%,')
216
+ })
217
+
218
+ it('does NOT animate a CLIP\'s opacity, even with a curve on it', () => {
219
+ // The one property that stays static on a clip: ffmpeg's colorchannelmixer
220
+ // takes its alpha gain as a <double> and accepts no expression, so a fade
221
+ // shown here could never be exported. Overlays are exempt — they bake in a
222
+ // browser. See keyframeOps.canKeyframeProp.
223
+ const item = {
224
+ id: 'kf-op', type: 'video', src: 'vid.mp4', start: 5, end: 15,
225
+ offsetX: 0, opacity: 0.6, inPoint: 0,
226
+ keyframes: [{ prop: 'opacity', points: [{ t: 0, value: 0 }, { t: 10, value: 1 }] }],
227
+ } as VisualItem
228
+
229
+ const at5 = renderLayer(item, { currentTime: 5 })
230
+ const o5 = at5.wrapper.style.opacity
231
+ at5.unmount()
232
+ const at10 = renderLayer(item, { currentTime: 10 })
233
+ expect(at10.wrapper.style.opacity).toBe(o5)
234
+ expect(Number(o5)).toBeCloseTo(0.6)
235
+ })
236
+
237
+ it('a live drag overrides the sampled curve value', () => {
238
+ const item = keyframedOverlay()
239
+ const { wrapper } = renderLayer(item, {
240
+ currentTime: 10,
241
+ liveOffset: { id: item.id, x: -33, y: 4 },
242
+ })
243
+
244
+ // Without the drag, offsetX at currentTime=10 (localT=5) samples to 50 —
245
+ // the drag value must win instead.
246
+ expect(wrapper.style.transform).toContain('translate(-33%, 4%)')
247
+ expect(wrapper.style.transform).not.toContain('translate(50%,')
248
+ })
249
+ })
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Per-axis scale. The preview's wrapper emits the two-argument
253
+ // `scale(scaleX, scaleY)`; every chrome element inside it counter-scales by the
254
+ // inverse of BOTH axes so handles stay square rather than stretching with the
255
+ // item. A legacy item that carries only the uniform `scale` must resolve both
256
+ // axes to that same number and render exactly the box it always did.
257
+ // ---------------------------------------------------------------------------
258
+
259
+ describe('OverlayItemsLayer — per-axis scale', () => {
260
+ const baseOverlay = { id: 'ax', type: 'overlay', src: 'o.jsx', start: 0, end: 10, props: {} }
261
+
262
+ it('renders a non-uniform item as scale(scaleX, scaleY)', () => {
263
+ const item = { ...baseOverlay, scaleX: 1.5, scaleY: 0.5 } as VisualItem
264
+ const { wrapper } = renderLayer(item, { currentTime: 1 })
265
+ expect(wrapper.style.transform).toContain('scale(1.5, 0.5)')
266
+ })
267
+
268
+ it('a legacy scale-only item renders both arguments equal (back-compat)', () => {
269
+ const item = { ...baseOverlay, scale: 1.3 } as VisualItem
270
+ const { wrapper } = renderLayer(item, { currentTime: 1 })
271
+ expect(wrapper.style.transform).toContain('scale(1.3, 1.3)')
272
+ // …and that is the resolver's doing, not this file's: the same fallback the
273
+ // renderer reads. Guards against the preview inventing its own default.
274
+ const g = geometryFor(item, 'overlay')
275
+ expect(g.scaleX).toBe(1.3)
276
+ expect(g.scaleY).toBe(1.3)
277
+ })
278
+
279
+ it('an item with NO scale at all renders scale(1, 1)', () => {
280
+ const item = { ...baseOverlay } as VisualItem
281
+ const { wrapper } = renderLayer(item, { currentTime: 1 })
282
+ expect(wrapper.style.transform).toContain('scale(1, 1)')
283
+ })
284
+
285
+ it('falls back to the uniform `scale` one axis at a time', () => {
286
+ // Only scaleY is per-axis; scaleX must inherit `scale`.
287
+ const item = { ...baseOverlay, scale: 2, scaleY: 0.25 } as VisualItem
288
+ const { wrapper } = renderLayer(item, { currentTime: 1 })
289
+ expect(wrapper.style.transform).toContain('scale(2, 0.25)')
290
+ })
291
+
292
+ it('a live resize drag overrides each axis independently', () => {
293
+ const item = { ...baseOverlay, scale: 1 } as VisualItem
294
+ const { wrapper } = renderLayer(item, {
295
+ currentTime: 1,
296
+ liveScale: { id: item.id, scale: 1, scaleX: 3, scaleY: 0.75 },
297
+ })
298
+ expect(wrapper.style.transform).toContain('scale(3, 0.75)')
299
+ })
300
+
301
+ it('samples keyframed scaleX/scaleY through geometryAt', () => {
302
+ const item = {
303
+ ...baseOverlay,
304
+ keyframes: [
305
+ { prop: 'scaleX', points: [{ t: 0, value: 1 }, { t: 10, value: 3 }] },
306
+ { prop: 'scaleY', points: [{ t: 0, value: 1 }, { t: 10, value: 2 }] },
307
+ ],
308
+ } as VisualItem
309
+
310
+ const at0 = renderLayer(item, { currentTime: 0 })
311
+ const g0 = geometryAt(item, 'overlay', 0)
312
+ expect(at0.wrapper.style.transform).toContain(`scale(${g0.scaleX}, ${g0.scaleY})`)
313
+ at0.unmount()
314
+
315
+ const at5 = renderLayer(item, { currentTime: 5 })
316
+ const g5 = geometryAt(item, 'overlay', 5)
317
+ expect(at5.wrapper.style.transform).toContain(`scale(${g5.scaleX}, ${g5.scaleY})`)
318
+
319
+ // Sanity: the axes actually diverge, so this cannot pass by rendering 1,1.
320
+ expect(g5.scaleX).not.toBe(g5.scaleY)
321
+ expect(g5.scaleX).not.toBe(g0.scaleX)
322
+ })
323
+
324
+ it('counter-scales the corner handles per axis so they stay square', () => {
325
+ const item = { ...baseOverlay, scaleX: 2, scaleY: 0.5 } as VisualItem
326
+ const { container } = renderLayer(item, { currentTime: 1, selected: true })
327
+ const nw = container.querySelector('.cursor-nw-resize') as HTMLElement
328
+ expect(nw).toBeTruthy()
329
+ // scale(1/2, 1/0.5), then the -50%/-50% that centres the square on the
330
+ // corner. A single scale(1/s) would leave the handle stretched.
331
+ expect(nw.style.transform).toBe('scale(0.5, 2) translate(-50%, -50%)')
332
+ const se = container.querySelector('.cursor-se-resize') as HTMLElement
333
+ expect(se.style.transform).toBe('scale(0.5, 2) translate(-50%, -50%)')
334
+ })
335
+
336
+ it('counter-scales the rotate handle per axis', () => {
337
+ const item = { ...baseOverlay, scaleX: 2, scaleY: 0.5 } as VisualItem
338
+ const { container } = renderLayer(item, { currentTime: 1, selected: true })
339
+ const rot = container.querySelector('.cursor-grab.flex-col') as HTMLElement
340
+ expect(rot).toBeTruthy()
341
+ expect(rot.style.transform).toBe('translateX(-50%) translateY(-100%) scale(0.5, 2)')
342
+ })
343
+
344
+ it('counter-scales the fit control per axis', () => {
345
+ const item = {
346
+ id: 'ax-img', type: 'image', src: 'i.png', start: 0, end: 10, scaleX: 4, scaleY: 0.25,
347
+ } as VisualItem
348
+ const { container, getByRole } = renderLayer(item, { currentTime: 1, selected: true, onOverlayChange: vi.fn() })
349
+ // Located through its own buttons rather than a colour class — the control
350
+ // is token-driven now, so there is no colour left to key off.
351
+ const fit = getByRole('button', { name: 'cover' }).parentElement as HTMLElement
352
+ expect(fit).toBeTruthy()
353
+ expect(container.contains(fit)).toBe(true)
354
+ expect(fit.style.transform).toBe('translateX(-50%) translateY(140%) scale(0.25, 4)')
355
+ })
356
+
357
+ it('a legacy uniform item still counter-scales to a square handle', () => {
358
+ const item = { ...baseOverlay, scale: 2 } as VisualItem
359
+ const { container } = renderLayer(item, { currentTime: 1, selected: true })
360
+ const nw = container.querySelector('.cursor-nw-resize') as HTMLElement
361
+ expect(nw.style.transform).toBe('scale(0.5, 0.5) translate(-50%, -50%)')
362
+ })
363
+ })