@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,195 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ maxExportResolution,
4
+ maxExportFps,
5
+ availableResolutionTiers,
6
+ availableFpsTiers,
7
+ currentResolutionTier,
8
+ } from '../export-limits'
9
+ import type { EditorProject as Project, VisualItem, VisualTrack } from '../../schema'
10
+
11
+ function vtracks(...items: VisualItem[][]): VisualTrack[] {
12
+ return items.map((its, i) => ({ id: `trk-${i}`, items: its }))
13
+ }
14
+
15
+ // Minimal project factory — only the fields export-limits touches.
16
+ function makeProject(over: Partial<Project> = {}): Project {
17
+ return {
18
+ id: 'p1',
19
+ status: 'draft',
20
+ settings: { resolution: [1080, 1920] },
21
+ tracks: vtracks([]),
22
+ ...over,
23
+ }
24
+ }
25
+
26
+ let nextId = 0
27
+ function videoClip(over: Partial<VisualItem> = {}): VisualItem {
28
+ nextId += 1
29
+ return { id: `v${nextId}`, type: 'video', start: 0, end: 5, ...over }
30
+ }
31
+
32
+ describe('maxExportResolution', () => {
33
+ it('returns settings.resolution verbatim when tracks is absent', () => {
34
+ const p = makeProject({ tracks: undefined })
35
+ expect(maxExportResolution(p)).toEqual([1080, 1920])
36
+ })
37
+
38
+ it('returns settings.resolution verbatim for empty tracks / no video items', () => {
39
+ const p = makeProject({ tracks: vtracks([], [{ id: 'o1', type: 'overlay', start: 0, end: 5 }]) })
40
+ expect(maxExportResolution(p)).toEqual([1080, 1920])
41
+ })
42
+
43
+ it('returns settings.resolution verbatim for a track shape with no items array (legacy tolerance)', () => {
44
+ // Legacy fixtures sometimes carry a track shape missing .items, or the whole
45
+ // tracks field. Normalizer must swallow both without throwing.
46
+ const p1 = makeProject({ tracks: [{ id: 't0' } as unknown as VisualTrack] })
47
+ expect(maxExportResolution(p1)).toEqual([1080, 1920])
48
+ const p2 = makeProject({ tracks: undefined })
49
+ expect(maxExportResolution(p2)).toEqual([1080, 1920])
50
+ })
51
+
52
+ it('returns settings.resolution verbatim when video items are missing sourceWidth/sourceHeight', () => {
53
+ const p = makeProject({ tracks: vtracks([videoClip()]) })
54
+ expect(maxExportResolution(p)).toEqual([1080, 1920])
55
+ })
56
+
57
+ it('returns the 1080 tier unchanged when all video items are 1080x1920', () => {
58
+ const p = makeProject({
59
+ settings: { resolution: [1080, 1920] },
60
+ tracks: vtracks([videoClip({ sourceWidth: 1080, sourceHeight: 1920 })]),
61
+ })
62
+ expect(maxExportResolution(p)).toEqual([1080, 1920])
63
+ })
64
+
65
+ it('returns [2160, 3840] for a portrait project with a 2160x3840 clip', () => {
66
+ const p = makeProject({
67
+ settings: { resolution: [1080, 1920] },
68
+ tracks: vtracks([videoClip({ sourceWidth: 2160, sourceHeight: 3840 })]),
69
+ })
70
+ expect(maxExportResolution(p)).toEqual([2160, 3840])
71
+ })
72
+
73
+ it('returns [3840, 2160] for a landscape project with a 3840x2160 clip', () => {
74
+ const p = makeProject({
75
+ settings: { resolution: [1920, 1080] },
76
+ tracks: vtracks([videoClip({ sourceWidth: 3840, sourceHeight: 2160 })]),
77
+ })
78
+ expect(maxExportResolution(p)).toEqual([3840, 2160])
79
+ })
80
+
81
+ it('picks the 4K tier when clips are mixed 1080p and 4K', () => {
82
+ const p = makeProject({
83
+ settings: { resolution: [1080, 1920] },
84
+ tracks: vtracks([
85
+ videoClip({ sourceWidth: 1080, sourceHeight: 1920 }),
86
+ videoClip({ sourceWidth: 2160, sourceHeight: 3840 }),
87
+ ]),
88
+ })
89
+ expect(maxExportResolution(p)).toEqual([2160, 3840])
90
+ })
91
+
92
+ it('returns settings.resolution verbatim when the source is smaller than the smallest tier', () => {
93
+ const p = makeProject({
94
+ settings: { resolution: [1080, 1920] },
95
+ tracks: vtracks([videoClip({ sourceWidth: 480, sourceHeight: 640 })]),
96
+ })
97
+ expect(maxExportResolution(p)).toEqual([1080, 1920])
98
+ })
99
+
100
+ it('keeps the long side proportional and even for a non-16:9 project aspect', () => {
101
+ const p = makeProject({
102
+ settings: { resolution: [1000, 1777] },
103
+ tracks: vtracks([videoClip({ sourceWidth: 1080, sourceHeight: 3000 })]),
104
+ })
105
+ const [w, h] = maxExportResolution(p)
106
+ expect(w).toBe(1080)
107
+ expect(h % 2).toBe(0)
108
+ // proportional to the project aspect (1777/1000), within half a pixel of rounding
109
+ expect(h / w).toBeCloseTo(1777 / 1000, 1)
110
+ })
111
+ })
112
+
113
+ describe('maxExportFps', () => {
114
+ it('returns 30 when settings.fps is 30', () => {
115
+ expect(maxExportFps(makeProject({ settings: { resolution: [1080, 1920], fps: 30 } }))).toBe(30)
116
+ })
117
+
118
+ it('defaults to 30 when settings.fps is absent', () => {
119
+ expect(maxExportFps(makeProject({ settings: { resolution: [1080, 1920] } }))).toBe(30)
120
+ })
121
+
122
+ it('returns 60 when settings.fps is 60', () => {
123
+ expect(maxExportFps(makeProject({ settings: { resolution: [1080, 1920], fps: 60 } }))).toBe(60)
124
+ })
125
+ })
126
+
127
+ describe('availableResolutionTiers', () => {
128
+ it('excludes 1440 and 2160 for a 1080p-sourced project', () => {
129
+ const p = makeProject({
130
+ settings: { resolution: [1080, 1920] },
131
+ tracks: vtracks([videoClip({ sourceWidth: 1080, sourceHeight: 1920 })]),
132
+ })
133
+ const tiers = availableResolutionTiers(p)
134
+ expect(tiers.some(([w, h]) => Math.min(w, h) === 1440)).toBe(false)
135
+ expect(tiers.some(([w, h]) => Math.min(w, h) === 2160)).toBe(false)
136
+ })
137
+
138
+ it('includes 1080 and 2160 for a 4K-sourced project', () => {
139
+ const p = makeProject({
140
+ settings: { resolution: [1080, 1920] },
141
+ tracks: vtracks([videoClip({ sourceWidth: 2160, sourceHeight: 3840 })]),
142
+ })
143
+ const tiers = availableResolutionTiers(p)
144
+ expect(tiers.some(([w, h]) => Math.min(w, h) === 1080)).toBe(true)
145
+ expect(tiers.some(([w, h]) => Math.min(w, h) === 2160)).toBe(true)
146
+ })
147
+
148
+ it('filters against the project own short-side when there are no video clips', () => {
149
+ const p = makeProject({ settings: { resolution: [1080, 1920] }, tracks: vtracks([]) })
150
+ const tiers = availableResolutionTiers(p)
151
+ expect(tiers.every(([w, h]) => Math.min(w, h) <= 1080)).toBe(true)
152
+ expect(tiers.some(([w, h]) => Math.min(w, h) === 1080)).toBe(true)
153
+ expect(tiers.some(([w, h]) => Math.min(w, h) === 1440)).toBe(false)
154
+ })
155
+
156
+ it('falls back to a single entry equal to settings.resolution when source is below every tier', () => {
157
+ const p = makeProject({
158
+ settings: { resolution: [1080, 1920] },
159
+ tracks: vtracks([videoClip({ sourceWidth: 480, sourceHeight: 640 })]),
160
+ })
161
+ expect(availableResolutionTiers(p)).toEqual([[1080, 1920]])
162
+ })
163
+ })
164
+
165
+ describe('availableFpsTiers', () => {
166
+ it('returns [24, 30] when settings.fps is 30', () => {
167
+ expect(availableFpsTiers(makeProject({ settings: { resolution: [1080, 1920], fps: 30 } }))).toEqual([24, 30])
168
+ })
169
+
170
+ it('returns [24, 30, 60] when settings.fps is 60', () => {
171
+ expect(availableFpsTiers(makeProject({ settings: { resolution: [1080, 1920], fps: 60 } }))).toEqual([
172
+ 24, 30, 60,
173
+ ])
174
+ })
175
+ })
176
+
177
+ describe('currentResolutionTier', () => {
178
+ it('returns [1080, 1920] for a matching project with a 4K source', () => {
179
+ const p = makeProject({
180
+ settings: { resolution: [1080, 1920] },
181
+ tracks: vtracks([videoClip({ sourceWidth: 2160, sourceHeight: 3840 })]),
182
+ })
183
+ expect(currentResolutionTier(p)).toEqual([1080, 1920])
184
+ })
185
+
186
+ it('returns the closest tier <= current when current matches no standard tier', () => {
187
+ const p = makeProject({
188
+ settings: { resolution: [1000, 2000] },
189
+ tracks: vtracks([videoClip({ sourceWidth: 2160, sourceHeight: 3840 })]),
190
+ })
191
+ const tier = currentResolutionTier(p)
192
+ expect(tier).toBeDefined()
193
+ expect(Math.min(...tier!)).toBe(720)
194
+ })
195
+ })
@@ -0,0 +1,163 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * The preview-axis override store. Small, but it carries the invariant the
4
+ * whole feature rests on: hover changes which frame the preview paints and
5
+ * NOTHING else — not the playhead, not where playback resumes.
6
+ */
7
+ import { describe, it, expect, vi } from 'vitest'
8
+ import { createHoverScrub, gateTimeSink, handOverToHover } from '../hover-scrub'
9
+ import { createPlaybackClock } from '../playback-clock'
10
+
11
+ describe('createHoverScrub', () => {
12
+ it('starts with no override — the preview follows the playhead', () => {
13
+ expect(createHoverScrub().get()).toBeNull()
14
+ })
15
+
16
+ it('notifies subscribers when the hovered time changes', () => {
17
+ const store = createHoverScrub()
18
+ const sub = vi.fn()
19
+ store.subscribe(sub)
20
+
21
+ store.set(4)
22
+ expect(store.get()).toBe(4)
23
+ expect(sub).toHaveBeenCalledTimes(1)
24
+
25
+ store.set(null)
26
+ expect(store.get()).toBeNull()
27
+ expect(sub).toHaveBeenCalledTimes(2)
28
+ })
29
+
30
+ it('stays silent on a repeat write', () => {
31
+ // Load-bearing: the clock subscription clears this on every playback tick,
32
+ // which without the guard would notify ~60 times a second forever.
33
+ const store = createHoverScrub()
34
+ const sub = vi.fn()
35
+ store.subscribe(sub)
36
+
37
+ store.set(null)
38
+ expect(sub).not.toHaveBeenCalled()
39
+
40
+ store.set(2)
41
+ store.set(2)
42
+ expect(sub).toHaveBeenCalledTimes(1)
43
+ })
44
+
45
+ it('unsubscribes cleanly', () => {
46
+ const store = createHoverScrub()
47
+ const sub = vi.fn()
48
+ store.subscribe(sub)()
49
+ store.set(1)
50
+ expect(sub).not.toHaveBeenCalled()
51
+ })
52
+
53
+ it('never touches the playback clock', () => {
54
+ // The invariant in one assertion: hovering must not move the playhead, or
55
+ // the red line and the scrubber handle would chase the pointer and Space
56
+ // would start playing from wherever it happened to rest.
57
+ const clock = createPlaybackClock()
58
+ const store = createHoverScrub()
59
+ clock.set(7)
60
+
61
+ store.set(1)
62
+ store.set(2)
63
+ expect(clock.get()).toBe(7)
64
+ })
65
+
66
+ it('is cleared by a clock move, the way VideoEditor wires it', () => {
67
+ // Any real playhead movement — a playback tick, an arrow step, a seek —
68
+ // cancels the hover preview, so a stale override can never pin the picture
69
+ // to a frame the playhead has left.
70
+ const clock = createPlaybackClock()
71
+ const store = createHoverScrub()
72
+ clock.subscribe(() => store.set(null))
73
+
74
+ store.set(3)
75
+ expect(store.get()).toBe(3)
76
+
77
+ clock.set(9)
78
+ expect(store.get()).toBeNull()
79
+ })
80
+ })
81
+
82
+ describe('the clock bridge, gated on hover-preview', () => {
83
+ // These drive the SAME functions `PreviewPlayer` wires in, rather than a copy
84
+ // of the rule — mounting the preview stack would need a real canvas and a
85
+ // decoder, but the rule itself is pure and belongs under test either way.
86
+ const makeTimeSink = gateTimeSink
87
+
88
+ it('drops the seek echo while hover-previewing, so the playhead stays put', () => {
89
+ // Showing a hovered frame seeks the source, and that seek reports its new
90
+ // position back. Unguarded, that is exactly what dragged the red playhead
91
+ // after the yellow cursor.
92
+ const clock = createPlaybackClock()
93
+ const hover = createHoverScrub()
94
+ const sink = makeTimeSink(clock, hover)
95
+ clock.set(10)
96
+
97
+ hover.set(42)
98
+ sink(42) // the echo of the hover seek
99
+ sink(42.5) // and the tick after it
100
+
101
+ expect(clock.get()).toBe(10)
102
+ })
103
+
104
+ it('lets playback through once the override is gone', () => {
105
+ const clock = createPlaybackClock()
106
+ const hover = createHoverScrub()
107
+ const sink = makeTimeSink(clock, hover)
108
+ clock.set(10)
109
+
110
+ hover.set(42)
111
+ sink(42)
112
+ expect(clock.get()).toBe(10)
113
+
114
+ hover.set(null)
115
+ sink(42.1)
116
+ expect(clock.get()).toBe(42.1)
117
+ })
118
+
119
+ it('hands the playhead over to the hovered frame when playback starts', () => {
120
+ // Play from the yellow line, and bring the red one with it, so the two
121
+ // agree rather than the playhead being stranded at its pre-hover position.
122
+ const clock = createPlaybackClock()
123
+ const hover = createHoverScrub()
124
+ clock.set(10)
125
+ hover.set(42)
126
+
127
+ expect(handOverToHover(clock, hover)).toBe(true)
128
+ expect(clock.get()).toBe(42)
129
+ expect(hover.get()).toBeNull()
130
+ })
131
+
132
+ it('leaves the playhead alone when play starts with no hover standing', () => {
133
+ // Every ordinary press of play: nothing hovered, nothing to hand over.
134
+ const clock = createPlaybackClock()
135
+ const hover = createHoverScrub()
136
+ clock.set(10)
137
+
138
+ expect(handOverToHover(clock, hover)).toBe(false)
139
+ expect(clock.get()).toBe(10)
140
+ })
141
+
142
+ it('is a no-op for a host that never wired the store', () => {
143
+ const clock = createPlaybackClock()
144
+ clock.set(10)
145
+ expect(handOverToHover(clock, undefined)).toBe(false)
146
+ gateTimeSink(clock, undefined)(3)
147
+ expect(clock.get()).toBe(3)
148
+ })
149
+
150
+ it('resumes writing straight after the hand-over', () => {
151
+ // The tick right after play must land, or the playhead would freeze at the
152
+ // hand-over point while the picture kept moving.
153
+ const clock = createPlaybackClock()
154
+ const hover = createHoverScrub()
155
+ const sink = gateTimeSink(clock, hover)
156
+ hover.set(42)
157
+ handOverToHover(clock, hover)
158
+
159
+ sink(42.2)
160
+ sink(42.4)
161
+ expect(clock.get()).toBe(42.4)
162
+ })
163
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { canKeyframe, canKeyframeProp, transformProps } from '../keyframeOps'
3
+ import type { VisualItem } from '../../schema'
4
+
5
+ /** SP9d — keyframeability is per property per kind. */
6
+ describe('canKeyframe / canKeyframeProp', () => {
7
+ const mk = (type: string, over: Partial<VisualItem> = {}) =>
8
+ ({ id: 'x', type, src: '/tmp/a', start: 0, end: 3, ...over } as unknown as VisualItem)
9
+
10
+ const FIVE = ['offsetX', 'offsetY', 'scale', 'rotation', 'opacity'] as const
11
+
12
+ it('overlays keep every property', () => {
13
+ const ov = mk('overlay')
14
+ expect(canKeyframe(ov)).toBe(true)
15
+ for (const p of FIVE) expect(canKeyframeProp(ov, p)).toBe(true)
16
+ })
17
+
18
+ for (const kind of ['video', 'image'] as const) {
19
+ it(`${kind} clips animate geometry but NOT opacity`, () => {
20
+ const clip = mk(kind)
21
+ expect(canKeyframe(clip)).toBe(true)
22
+ for (const p of ['offsetX', 'offsetY', 'scale', 'rotation'] as const) {
23
+ expect(canKeyframeProp(clip, p)).toBe(true)
24
+ }
25
+ expect(canKeyframeProp(clip, 'opacity')).toBe(false)
26
+ })
27
+ }
28
+
29
+ it('an unknown or missing kind is keyframeable nowhere', () => {
30
+ expect(canKeyframe(null)).toBe(false)
31
+ expect(canKeyframe(undefined)).toBe(false)
32
+ expect(canKeyframe(mk('audio'))).toBe(false)
33
+ expect(canKeyframeProp(mk('audio'), 'scale')).toBe(false)
34
+ expect(canKeyframeProp(null, 'scale')).toBe(false)
35
+ })
36
+
37
+ it('canKeyframe is true iff SOME property is keyframeable', () => {
38
+ for (const kind of ['overlay', 'video', 'image', 'audio', 'caption']) {
39
+ const item = mk(kind)
40
+ const anyProp = FIVE.some(p => canKeyframeProp(item, p))
41
+ expect(canKeyframe(item)).toBe(anyProp)
42
+ }
43
+ })
44
+
45
+ it('transformProps drops opacity for a clip and keeps it for an overlay', () => {
46
+ expect(transformProps(mk('overlay'))).toContain('opacity')
47
+ expect(transformProps(mk('video'))).not.toContain('opacity')
48
+ expect(transformProps(mk('image'))).not.toContain('opacity')
49
+ // ...and still carries the geometry props, so keying a clip does something.
50
+ expect(transformProps(mk('video'))).toContain('scale')
51
+ expect(transformProps(mk('video'))).toContain('rotation')
52
+ })
53
+
54
+ it('a per-axis clip drops opacity too, and keeps scaleX/scaleY', () => {
55
+ const clip = mk('video', { scaleX: 1.2, scaleY: 0.8 } as Partial<VisualItem>)
56
+ const props = transformProps(clip)
57
+ expect(props).not.toContain('opacity')
58
+ expect(props).toContain('scaleX')
59
+ expect(props).toContain('scaleY')
60
+ })
61
+ })