@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,319 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, within, cleanup } from '@testing-library/react'
3
+ import type { EditorAdapter, Project, ImageElement } from '../../types'
4
+ import type { VisualTrack, VisualItem } from '../../schema'
5
+ import RenderModal from '../RenderModal'
6
+ import { availableResolutionTiers, availableFpsTiers } from '../export-limits'
7
+
8
+ afterEach(cleanup)
9
+
10
+ // ── Export dialog — resolution + fps tier pickers ──────────────────────────────
11
+ // RenderModal.tsx renders these as two radiogroups ("Resolution" / "Frame rate")
12
+ // between "Save to" and the HDR-only "Format" block, only when the host supplies
13
+ // the corresponding `preRenderOptions.resolution` / `.fps` control. Copied
14
+ // factories from RenderModal.options.test.tsx — that file does not export them.
15
+
16
+ function baseAdapter(): EditorAdapter<Project> {
17
+ return {
18
+ loadProject: vi.fn(),
19
+ saveProject: vi.fn(),
20
+ subscribe: () => () => {},
21
+ render: vi.fn(async function* () {}),
22
+ resolveImageSrc: (el: ImageElement) => el.src,
23
+ compileOverlay: vi.fn(async () => () => null),
24
+ listGlobalOverlays: vi.fn(async () => []),
25
+ listSystemOverlays: vi.fn(async () => []),
26
+ uploadFile: vi.fn(async () => ''),
27
+ fileUrl: (p: string) => `/files?path=${p}`,
28
+ } as unknown as EditorAdapter<Project>
29
+ }
30
+
31
+ /** An adapter on the poll transport (renderAsync + getRenderStatus). */
32
+ function pollAdapter(): EditorAdapter<Project> {
33
+ const a = baseAdapter()
34
+ a.renderAsync = vi.fn(async () => ({ status: 'running' }))
35
+ a.getRenderStatus = vi.fn(async () => ({ status: 'running' as const, phase: 'rendering' as const }))
36
+ return a
37
+ }
38
+
39
+ const KEEPS = [{ start: 0, end: 12 }]
40
+
41
+ // Minimal project factory + helpers, for composing preRenderOptions.resolution
42
+ // from the real export-limits functions (same shape as export-limits.test.ts).
43
+ function vtracks(...items: VisualItem[][]): VisualTrack[] {
44
+ return items.map((its, i) => ({ id: `trk-${i}`, items: its }))
45
+ }
46
+
47
+ let nextId = 0
48
+ function videoClip(over: Partial<VisualItem> = {}): VisualItem {
49
+ nextId += 1
50
+ return { id: `v${nextId}`, type: 'video', start: 0, end: 5, ...over }
51
+ }
52
+
53
+ function makeProject(over: Partial<Project> = {}): Project {
54
+ return {
55
+ id: 'p1',
56
+ status: 'draft',
57
+ settings: { resolution: [1080, 1920] },
58
+ tracks: vtracks([]),
59
+ ...over,
60
+ }
61
+ }
62
+
63
+ const resGroup = () => screen.getByRole('radiogroup', { name: 'Resolution' })
64
+ const fpsGroup = () => screen.getByRole('radiogroup', { name: 'Frame rate' })
65
+
66
+ describe('RenderModal — Resolution radiogroup', () => {
67
+ it('renders when preRenderOptions.resolution is present, with the active tile matching value', () => {
68
+ render(
69
+ <RenderModal
70
+ adapter={pollAdapter()}
71
+ projectId="vid-1"
72
+ onClose={vi.fn()}
73
+ preRenderOptions={{
74
+ isHdr: false,
75
+ keeps: KEEPS,
76
+ resolution: { value: [1080, 1920], available: [[720, 1280], [1080, 1920]], set: vi.fn() },
77
+ }}
78
+ />,
79
+ )
80
+
81
+ const radios = within(resGroup()).getAllByRole('radio')
82
+ expect(radios).toHaveLength(2)
83
+ const active = radios.find(r => r.getAttribute('aria-checked') === 'true')
84
+ expect(active).toBeTruthy()
85
+ expect(within(active!).getByText('1080p (HD)')).toBeTruthy()
86
+ })
87
+
88
+ it('is absent from the DOM when preRenderOptions.resolution is absent', () => {
89
+ render(
90
+ <RenderModal
91
+ adapter={pollAdapter()}
92
+ projectId="vid-1"
93
+ onClose={vi.fn()}
94
+ preRenderOptions={{ isHdr: false, keeps: KEEPS }}
95
+ />,
96
+ )
97
+
98
+ expect(screen.queryByRole('radiogroup', { name: 'Resolution' })).toBeNull()
99
+ })
100
+
101
+ it('does not offer 1440p or 2160p for a 1080p-source-only project', () => {
102
+ const project = makeProject({
103
+ settings: { resolution: [1080, 1920] },
104
+ tracks: vtracks([videoClip({ sourceWidth: 1080, sourceHeight: 1920 })]),
105
+ })
106
+ const available = availableResolutionTiers(project)
107
+
108
+ render(
109
+ <RenderModal
110
+ adapter={pollAdapter()}
111
+ projectId="vid-1"
112
+ onClose={vi.fn()}
113
+ preRenderOptions={{
114
+ isHdr: false,
115
+ keeps: KEEPS,
116
+ resolution: { value: [1080, 1920], available, set: vi.fn() },
117
+ }}
118
+ />,
119
+ )
120
+
121
+ expect(within(resGroup()).queryByText(/1440p/)).toBeNull()
122
+ expect(within(resGroup()).queryByText(/2160p/)).toBeNull()
123
+ })
124
+
125
+ it('offers up to 2160p for a 4K-source project (1080p also available)', () => {
126
+ const project = makeProject({
127
+ settings: { resolution: [1080, 1920] },
128
+ tracks: vtracks([videoClip({ sourceWidth: 2160, sourceHeight: 3840 })]),
129
+ })
130
+ const available = availableResolutionTiers(project)
131
+
132
+ render(
133
+ <RenderModal
134
+ adapter={pollAdapter()}
135
+ projectId="vid-1"
136
+ onClose={vi.fn()}
137
+ preRenderOptions={{
138
+ isHdr: false,
139
+ keeps: KEEPS,
140
+ resolution: { value: [1080, 1920], available, set: vi.fn() },
141
+ }}
142
+ />,
143
+ )
144
+
145
+ const radios = within(resGroup()).getAllByRole('radio')
146
+ const tile2160 = radios.find(r => within(r).queryByText('2160p (4K)') !== null)
147
+ const tile1080 = radios.find(r => within(r).queryByText('1080p (HD)') !== null)
148
+ expect(tile2160).toBeTruthy()
149
+ expect(tile1080).toBeTruthy()
150
+ })
151
+
152
+ it('calls set with the clicked tier, never the current value', () => {
153
+ const set = vi.fn()
154
+ render(
155
+ <RenderModal
156
+ adapter={pollAdapter()}
157
+ projectId="vid-1"
158
+ onClose={vi.fn()}
159
+ preRenderOptions={{
160
+ isHdr: false,
161
+ keeps: KEEPS,
162
+ resolution: { value: [1080, 1920], available: [[720, 1280], [1080, 1920]], set },
163
+ }}
164
+ />,
165
+ )
166
+
167
+ const radios = within(resGroup()).getAllByRole('radio')
168
+ const tile720 = radios.find(r => within(r).queryByText('720p') !== null)!
169
+ fireEvent.click(tile720)
170
+
171
+ expect(set).toHaveBeenCalledTimes(1)
172
+ expect(set).toHaveBeenCalledWith([720, 1280])
173
+ expect(set).not.toHaveBeenCalledWith([1080, 1920])
174
+ })
175
+
176
+ it('renders a single button for a single-tier available list', () => {
177
+ render(
178
+ <RenderModal
179
+ adapter={pollAdapter()}
180
+ projectId="vid-1"
181
+ onClose={vi.fn()}
182
+ preRenderOptions={{
183
+ isHdr: false,
184
+ keeps: KEEPS,
185
+ resolution: { value: [1080, 1920], available: [[1080, 1920]], set: vi.fn() },
186
+ }}
187
+ />,
188
+ )
189
+
190
+ expect(within(resGroup()).getAllByRole('radio')).toHaveLength(1)
191
+ })
192
+
193
+ it('reflects an updated resolution.value as the active tile on re-render', () => {
194
+ const set = vi.fn()
195
+ const adapter = pollAdapter()
196
+ const available: Array<[number, number]> = [[720, 1280], [1080, 1920]]
197
+
198
+ const { rerender } = render(
199
+ <RenderModal
200
+ adapter={adapter}
201
+ projectId="vid-1"
202
+ onClose={vi.fn()}
203
+ preRenderOptions={{ isHdr: false, keeps: KEEPS, resolution: { value: [1080, 1920], available, set } }}
204
+ />,
205
+ )
206
+
207
+ let active = within(resGroup()).getAllByRole('radio').find(r => r.getAttribute('aria-checked') === 'true')!
208
+ expect(within(active).getByText('1080p (HD)')).toBeTruthy()
209
+
210
+ rerender(
211
+ <RenderModal
212
+ adapter={adapter}
213
+ projectId="vid-1"
214
+ onClose={vi.fn()}
215
+ preRenderOptions={{ isHdr: false, keeps: KEEPS, resolution: { value: [720, 1280], available, set } }}
216
+ />,
217
+ )
218
+
219
+ active = within(resGroup()).getAllByRole('radio').find(r => r.getAttribute('aria-checked') === 'true')!
220
+ expect(within(active).getByText('720p')).toBeTruthy()
221
+ })
222
+ })
223
+
224
+ describe('RenderModal — Frame rate radiogroup', () => {
225
+ it('renders when preRenderOptions.fps is present, with the active tile matching value', () => {
226
+ render(
227
+ <RenderModal
228
+ adapter={pollAdapter()}
229
+ projectId="vid-1"
230
+ onClose={vi.fn()}
231
+ preRenderOptions={{ isHdr: false, keeps: KEEPS, fps: { value: 30, available: [24, 30], set: vi.fn() } }}
232
+ />,
233
+ )
234
+
235
+ const radios = within(fpsGroup()).getAllByRole('radio')
236
+ expect(radios).toHaveLength(2)
237
+ const active = radios.find(r => r.getAttribute('aria-checked') === 'true')
238
+ expect(active).toBeTruthy()
239
+ expect(within(active!).getByText('30 fps')).toBeTruthy()
240
+ })
241
+
242
+ it('is absent from the DOM when preRenderOptions.fps is absent', () => {
243
+ render(
244
+ <RenderModal
245
+ adapter={pollAdapter()}
246
+ projectId="vid-1"
247
+ onClose={vi.fn()}
248
+ preRenderOptions={{ isHdr: false, keeps: KEEPS }}
249
+ />,
250
+ )
251
+
252
+ expect(screen.queryByRole('radiogroup', { name: 'Frame rate' })).toBeNull()
253
+ })
254
+
255
+ it('calls set with the clicked fps tier', () => {
256
+ const set = vi.fn()
257
+ render(
258
+ <RenderModal
259
+ adapter={pollAdapter()}
260
+ projectId="vid-1"
261
+ onClose={vi.fn()}
262
+ preRenderOptions={{ isHdr: false, keeps: KEEPS, fps: { value: 30, available: [24, 30], set } }}
263
+ />,
264
+ )
265
+
266
+ const radios = within(fpsGroup()).getAllByRole('radio')
267
+ const tile24 = radios.find(r => within(r).queryByText('24 fps') !== null)!
268
+ fireEvent.click(tile24)
269
+
270
+ expect(set).toHaveBeenCalledTimes(1)
271
+ expect(set).toHaveBeenCalledWith(24)
272
+ })
273
+
274
+ it('composes a full fps tier set (up to 60) for a 60fps project via availableFpsTiers', () => {
275
+ const project = makeProject({ settings: { resolution: [1080, 1920], fps: 60 } })
276
+ const available = availableFpsTiers(project)
277
+ expect(available).toEqual([24, 30, 60])
278
+
279
+ render(
280
+ <RenderModal
281
+ adapter={pollAdapter()}
282
+ projectId="vid-1"
283
+ onClose={vi.fn()}
284
+ preRenderOptions={{ isHdr: false, keeps: KEEPS, fps: { value: 60, available, set: vi.fn() } }}
285
+ />,
286
+ )
287
+
288
+ expect(within(fpsGroup()).getAllByRole('radio')).toHaveLength(3)
289
+ })
290
+ })
291
+
292
+ describe('RenderModal — export controls DOM order', () => {
293
+ it('orders Name, Resolution, Frame rate, then Format (HDR) top to bottom', () => {
294
+ render(
295
+ <RenderModal
296
+ adapter={pollAdapter()}
297
+ projectId="vid-1"
298
+ onClose={vi.fn()}
299
+ preRenderOptions={{
300
+ isHdr: true,
301
+ keeps: KEEPS,
302
+ resolution: { value: [1080, 1920], available: [[720, 1280], [1080, 1920]], set: vi.fn() },
303
+ fps: { value: 30, available: [24, 30], set: vi.fn() },
304
+ }}
305
+ />,
306
+ )
307
+
308
+ const text = document.body.textContent ?? ''
309
+ const iName = text.indexOf('Name')
310
+ const iResolution = text.indexOf('Resolution')
311
+ const iFrameRate = text.indexOf('Frame rate')
312
+ const iFormat = text.indexOf('Format')
313
+
314
+ expect(iName).toBeGreaterThanOrEqual(0)
315
+ expect(iResolution).toBeGreaterThan(iName)
316
+ expect(iFrameRate).toBeGreaterThan(iResolution)
317
+ expect(iFormat).toBeGreaterThan(iFrameRate)
318
+ })
319
+ })