@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,562 @@
1
+ import { StrictMode } from 'react'
2
+ import { describe, it, expect, vi, afterEach } from 'vitest'
3
+ import { render, screen, waitFor, fireEvent, cleanup, within } from '@testing-library/react'
4
+ import type { EditorAdapter, ImageElement, Project } from '../../types'
5
+ import RenderModal, { pickSampleTime } from '../RenderModal'
6
+
7
+ afterEach(cleanup)
8
+
9
+ // ── The Export dialog ─────────────────────────────────────────────────────────
10
+ // The modal historically fired the render on mount with no inputs. Hosts that
11
+ // pass `preRenderOptions` (montaj always does — HDR and SDR alike) now open on
12
+ // the Export dialog first; hosts that omit it keep the old fire-on-mount
13
+ // behavior exactly, which is the regression these tests pin.
14
+
15
+ function baseAdapter(): EditorAdapter<Project> {
16
+ return {
17
+ loadProject: vi.fn(),
18
+ saveProject: vi.fn(),
19
+ subscribe: () => () => {},
20
+ render: vi.fn(async function* () {}),
21
+ resolveImageSrc: (el: ImageElement) => el.src,
22
+ compileOverlay: vi.fn(async () => () => null),
23
+ listGlobalOverlays: vi.fn(async () => []),
24
+ listSystemOverlays: vi.fn(async () => []),
25
+ uploadFile: vi.fn(async () => ''),
26
+ fileUrl: (p: string) => `/files?path=${p}`,
27
+ } as unknown as EditorAdapter<Project>
28
+ }
29
+
30
+ /** An adapter on the poll transport (renderAsync + getRenderStatus). */
31
+ function pollAdapter(): EditorAdapter<Project> {
32
+ const a = baseAdapter()
33
+ a.renderAsync = vi.fn(async () => ({ status: 'running' }))
34
+ a.getRenderStatus = vi.fn(async () => ({ status: 'running' as const, phase: 'rendering' as const }))
35
+ return a
36
+ }
37
+
38
+ const KEEPS = [{ start: 0, end: 12 }]
39
+
40
+ // Query helpers that disambiguate the dialog title (heading) from the primary
41
+ // action (button), which share the label "Export".
42
+ const exportButton = () => screen.getByRole('button', { name: 'Export' })
43
+ const exportHeading = () => screen.getByRole('heading', { name: 'Export' })
44
+
45
+ describe('pickSampleTime', () => {
46
+ it('lands inside the chosen keep, away from its edges', () => {
47
+ const at = pickSampleTime([{ start: 10, end: 20 }], () => 0.5)!
48
+ expect(at).toBeGreaterThan(10)
49
+ expect(at).toBeLessThan(20)
50
+ // Middle 60% band: 0.2 + 0.5 * 0.6 = 0.5 of the span.
51
+ expect(at).toBeCloseTo(15, 5)
52
+ })
53
+
54
+ it('picks from the keeps it is given', () => {
55
+ // rand() = 0 → first keep, earliest point of the sampling band.
56
+ expect(pickSampleTime([{ start: 0, end: 10 }, { start: 100, end: 110 }], () => 0)).toBeCloseTo(2, 5)
57
+ })
58
+
59
+ it('returns null when there is nothing worth sampling', () => {
60
+ expect(pickSampleTime(undefined)).toBeNull()
61
+ expect(pickSampleTime([])).toBeNull()
62
+ // Slivers are skipped — a frame from a 0.1s keep is not a useful sample.
63
+ expect(pickSampleTime([{ start: 4, end: 4.1 }])).toBeNull()
64
+ })
65
+ })
66
+
67
+ describe('RenderModal — fire-on-mount (no preRenderOptions)', () => {
68
+ it('fires the render on mount when preRenderOptions is absent', async () => {
69
+ const adapter = pollAdapter()
70
+ render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
71
+
72
+ await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
73
+ expect(screen.queryByRole('heading', { name: 'Export' })).toBeNull()
74
+ expect(screen.getByText('Rendering…')).toBeTruthy()
75
+ })
76
+
77
+ it('passes no render options through when there was nothing to choose', async () => {
78
+ const adapter = pollAdapter()
79
+ render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
80
+
81
+ await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
82
+ expect(adapter.renderAsync).toHaveBeenCalledWith('vid-1', undefined)
83
+ })
84
+ })
85
+
86
+ describe('RenderModal — Export dialog', () => {
87
+ it('opens for an HDR project and does NOT start the render', async () => {
88
+ const adapter = pollAdapter()
89
+ render(
90
+ <RenderModal
91
+ adapter={adapter}
92
+ projectId="vid-1"
93
+ onClose={vi.fn()}
94
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
95
+ />,
96
+ )
97
+
98
+ expect(exportHeading()).toBeTruthy()
99
+ expect(screen.getByText('Match footage (HDR)')).toBeTruthy()
100
+ expect(exportButton()).toBeTruthy()
101
+
102
+ // Give any effect a chance to misfire before asserting it did not.
103
+ await waitFor(() => expect(screen.getByText('Advanced')).toBeTruthy())
104
+ expect(adapter.renderAsync).not.toHaveBeenCalled()
105
+ expect(adapter.render).not.toHaveBeenCalled()
106
+ })
107
+
108
+ it('opens for an SDR project and hides the HDR-only controls', async () => {
109
+ const adapter = pollAdapter()
110
+ render(
111
+ <RenderModal
112
+ adapter={adapter}
113
+ projectId="vid-1"
114
+ onClose={vi.fn()}
115
+ preRenderOptions={{ isHdr: false, keeps: KEEPS, name: 'clip' }}
116
+ />,
117
+ )
118
+
119
+ // The dialog shows, with Name + Save-to, but none of the HDR controls.
120
+ expect(exportHeading()).toBeTruthy()
121
+ expect(screen.getByText('Name')).toBeTruthy()
122
+ expect(screen.getByText('Save to')).toBeTruthy()
123
+ expect(screen.queryByText('Format')).toBeNull()
124
+ expect(screen.queryByText('Match footage (HDR)')).toBeNull()
125
+ expect(screen.queryByText('Image color')).toBeNull()
126
+ expect(screen.queryByText('Advanced')).toBeNull()
127
+
128
+ // Nothing renders until the user hits Export.
129
+ await waitFor(() => expect(screen.getByText('Save to')).toBeTruthy())
130
+ expect(adapter.renderAsync).not.toHaveBeenCalled()
131
+
132
+ fireEvent.click(exportButton())
133
+ await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
134
+ })
135
+ })
136
+
137
+ describe('RenderModal — name + cover', () => {
138
+ it('threads the edited name and chosen cover into the render opts (poll)', async () => {
139
+ const adapter = pollAdapter()
140
+ adapter.getSampleFrame = vi.fn(async () => ({ url: '/files?path=/cover.png' }))
141
+ render(
142
+ <RenderModal
143
+ adapter={adapter}
144
+ projectId="vid-1"
145
+ onClose={vi.fn()}
146
+ preRenderOptions={{ isHdr: true, keeps: KEEPS, durationSec: 12, name: 'export' }}
147
+ />,
148
+ )
149
+
150
+ fireEvent.change(screen.getByPlaceholderText('export'), { target: { value: 'my-clip' } })
151
+ fireEvent.click(screen.getByText('Edit cover'))
152
+ // Cover is a frame-tile picker with a fixed 10-frame grid. durationSec 12 →
153
+ // tiles at ((i+0.5)/10)*12 = 0.6 / 1.8 / 3.0 / …; clicking the second (index
154
+ // 1) sets cover to 1.8.
155
+ const tiles = within(screen.getByRole('radiogroup', { name: 'Cover frame' })).getAllByRole('radio')
156
+ expect(tiles).toHaveLength(10)
157
+ fireEvent.click(tiles[1])
158
+ fireEvent.click(exportButton())
159
+
160
+ await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
161
+ expect(adapter.renderAsync).toHaveBeenCalledWith('vid-1', expect.objectContaining({
162
+ export: 'auto',
163
+ sdrCurve: 'vivid1',
164
+ name: 'my-clip',
165
+ cover: expect.closeTo(1.8, 5),
166
+ }))
167
+ })
168
+
169
+ it('defaults the name to the host-suggested value', async () => {
170
+ const adapter = pollAdapter()
171
+ render(
172
+ <RenderModal
173
+ adapter={adapter}
174
+ projectId="vid-1"
175
+ onClose={vi.fn()}
176
+ preRenderOptions={{ isHdr: true, keeps: KEEPS, name: 'reel-2' }}
177
+ />,
178
+ )
179
+
180
+ expect((screen.getByPlaceholderText('export') as HTMLInputElement).value).toBe('reel-2')
181
+ fireEvent.click(exportButton())
182
+ await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
183
+ expect(adapter.renderAsync).toHaveBeenCalledWith('vid-1', expect.objectContaining({ name: 'reel-2' }))
184
+ })
185
+ })
186
+
187
+ describe('RenderModal — HDR format + curve', () => {
188
+ it('starts the render with the chosen format and curve (poll transport)', async () => {
189
+ const adapter = pollAdapter()
190
+ render(
191
+ <RenderModal
192
+ adapter={adapter}
193
+ projectId="vid-1"
194
+ onClose={vi.fn()}
195
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
196
+ />,
197
+ )
198
+
199
+ fireEvent.click(screen.getByText('Both'))
200
+ fireEvent.click(screen.getByText('Advanced'))
201
+ fireEvent.click(screen.getByText('Neutral brights'))
202
+ fireEvent.click(exportButton())
203
+
204
+ await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
205
+ expect(adapter.renderAsync).toHaveBeenCalledWith('vid-1', expect.objectContaining({
206
+ export: 'both',
207
+ sdrCurve: 'vivid1-neutral',
208
+ }))
209
+ expect(screen.queryByRole('heading', { name: 'Export' })).toBeNull()
210
+ })
211
+
212
+ it('starts the render with the chosen format and curve (SSE transport)', async () => {
213
+ const adapter = baseAdapter()
214
+ render(
215
+ <RenderModal
216
+ adapter={adapter}
217
+ projectId="vid-1"
218
+ onClose={vi.fn()}
219
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
220
+ />,
221
+ )
222
+
223
+ fireEvent.click(screen.getByText('SDR'))
224
+ fireEvent.click(exportButton())
225
+
226
+ await waitFor(() => expect(adapter.render).toHaveBeenCalledTimes(1))
227
+ expect(adapter.render).toHaveBeenCalledWith('vid-1', expect.objectContaining({
228
+ export: 'sdr',
229
+ sdrCurve: 'vivid1',
230
+ }))
231
+ })
232
+
233
+ it('defaults to matching the footage on the default curve', async () => {
234
+ const adapter = pollAdapter()
235
+ render(
236
+ <RenderModal
237
+ adapter={adapter}
238
+ projectId="vid-1"
239
+ onClose={vi.fn()}
240
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
241
+ />,
242
+ )
243
+
244
+ fireEvent.click(exportButton())
245
+ await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
246
+ expect(adapter.renderAsync).toHaveBeenCalledWith('vid-1', expect.objectContaining({
247
+ export: 'auto',
248
+ sdrCurve: 'vivid1',
249
+ }))
250
+ })
251
+
252
+ it('shows the SDR derive step only when an SDR file is derived', async () => {
253
+ const adapter = pollAdapter()
254
+ const { unmount } = render(
255
+ <RenderModal
256
+ adapter={adapter}
257
+ projectId="vid-1"
258
+ onClose={vi.fn()}
259
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
260
+ />,
261
+ )
262
+
263
+ // Default (auto) → no derive step in the stepper.
264
+ fireEvent.click(exportButton())
265
+ await waitFor(() => expect(screen.getByText('Rendering graphics')).toBeTruthy())
266
+ expect(screen.queryByText('Deriving SDR')).toBeNull()
267
+ unmount()
268
+
269
+ render(
270
+ <RenderModal
271
+ adapter={pollAdapter()}
272
+ projectId="vid-2"
273
+ onClose={vi.fn()}
274
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
275
+ />,
276
+ )
277
+ fireEvent.click(screen.getByText('Both'))
278
+ fireEvent.click(exportButton())
279
+ await waitFor(() => expect(screen.getByText('Deriving SDR')).toBeTruthy())
280
+ })
281
+ })
282
+
283
+ describe('RenderModal — curve picker honesty line', () => {
284
+ const DEFAULT_LINE = 'SDR export matches this preview; HDR export adds highlight range on HDR displays.'
285
+
286
+ function openAdvanced() {
287
+ const adapter = pollAdapter()
288
+ render(
289
+ <RenderModal
290
+ adapter={adapter}
291
+ projectId="vid-1"
292
+ onClose={vi.fn()}
293
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
294
+ />,
295
+ )
296
+ fireEvent.click(screen.getByText('Advanced'))
297
+ return adapter
298
+ }
299
+
300
+ it('states how the export relates to the preview on the default curve', () => {
301
+ openAdvanced()
302
+ expect(screen.getByTestId('sdr-honesty-line').textContent).toBe(DEFAULT_LINE)
303
+ })
304
+
305
+ it('swaps to a differs-from-preview caveat on a non-default curve', () => {
306
+ openAdvanced()
307
+ fireEvent.click(screen.getByText('Neutral brights'))
308
+
309
+ const line = screen.getByTestId('sdr-honesty-line').textContent ?? ''
310
+ expect(line).not.toBe(DEFAULT_LINE)
311
+ expect(line).toMatch(/will not match your preview/i)
312
+ expect(line).toMatch(/Montaj Vivid/)
313
+
314
+ // And back again.
315
+ fireEvent.click(screen.getByText('Montaj Vivid'))
316
+ expect(screen.getByTestId('sdr-honesty-line').textContent).toBe(DEFAULT_LINE)
317
+ })
318
+
319
+ it('keeps every option string free of em dashes (project copy rule)', () => {
320
+ openAdvanced()
321
+ expect(document.body.textContent ?? '').not.toContain('—')
322
+ })
323
+ })
324
+
325
+ describe('RenderModal — curve thumbnails', () => {
326
+ it('samples the same frame once per curve', async () => {
327
+ const adapter = pollAdapter()
328
+ adapter.getSampleFrame = vi.fn(async (_id: string, _at: number, opts?: { sdrCurve?: string }) => ({
329
+ url: `/files?path=/sample-${opts?.sdrCurve}.png`,
330
+ }))
331
+
332
+ render(
333
+ <RenderModal
334
+ adapter={adapter}
335
+ projectId="vid-1"
336
+ onClose={vi.fn()}
337
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
338
+ />,
339
+ )
340
+
341
+ // Curve samples are deferred until Advanced is opened (they only show
342
+ // there). Opening it fires exactly one sample per curve; the cover sample is
343
+ // debounced, so it hasn't landed by the time these two resolve.
344
+ fireEvent.click(screen.getByText('Advanced'))
345
+ await waitFor(() => expect(adapter.getSampleFrame).toHaveBeenCalledTimes(2))
346
+ const calls = (adapter.getSampleFrame as ReturnType<typeof vi.fn>).mock.calls
347
+ expect(calls.map(c => c[2]?.sdrCurve).sort()).toEqual(['vivid1', 'vivid1-neutral'])
348
+ // Both curves must be judged on the SAME frame, inside the given keep.
349
+ expect(calls[0][1]).toBe(calls[1][1])
350
+ expect(calls[0][1]).toBeGreaterThan(0)
351
+ expect(calls[0][1]).toBeLessThan(12)
352
+
353
+ await waitFor(() => {
354
+ const srcs = Array.from(document.querySelectorAll('img')).map(i => i.getAttribute('src'))
355
+ expect(srcs).toContain('/files?path=/sample-vivid1.png')
356
+ expect(srcs).toContain('/files?path=/sample-vivid1-neutral.png')
357
+ })
358
+ })
359
+
360
+ it('still paints thumbnails under StrictMode double-mount (M3 regression)', async () => {
361
+ // StrictMode fires mount→cleanup→mount in dev. The old effect flipped a
362
+ // per-invocation `alive` flag false in mount #1's cleanup, while mount #2
363
+ // hit the dedup ref-guard and returned without re-arming it — so the fetched
364
+ // thumbnails resolved into a dead closure and never painted. Sampling still
365
+ // ran once (the dedup), which is why it looked like it "worked" everywhere
366
+ // but the screen. Production has no double-invoke, so this was dev-only.
367
+ const adapter = pollAdapter()
368
+ adapter.getSampleFrame = vi.fn(async (_id: string, _at: number, opts?: { sdrCurve?: string }) => ({
369
+ url: `/files?path=/sample-${opts?.sdrCurve}.png`,
370
+ }))
371
+
372
+ render(
373
+ <StrictMode>
374
+ <RenderModal
375
+ adapter={adapter}
376
+ projectId="vid-1"
377
+ onClose={vi.fn()}
378
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
379
+ />
380
+ </StrictMode>,
381
+ )
382
+
383
+ // Open Advanced to trigger the (now-deferred) curve sampling. Dedup still
384
+ // holds: exactly one sample per curve despite the double-mount.
385
+ fireEvent.click(screen.getByText('Advanced'))
386
+ await waitFor(() => expect(adapter.getSampleFrame).toHaveBeenCalledTimes(2))
387
+
388
+ await waitFor(() => {
389
+ const srcs = Array.from(document.querySelectorAll('img')).map(i => i.getAttribute('src'))
390
+ expect(srcs).toContain('/files?path=/sample-vivid1.png')
391
+ expect(srcs).toContain('/files?path=/sample-vivid1-neutral.png')
392
+ })
393
+ })
394
+
395
+ it('renders the picker with labels only when the adapter cannot sample', async () => {
396
+ const adapter = pollAdapter() // no getSampleFrame at all
397
+ render(
398
+ <RenderModal
399
+ adapter={adapter}
400
+ projectId="vid-1"
401
+ onClose={vi.fn()}
402
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
403
+ />,
404
+ )
405
+
406
+ fireEvent.click(screen.getByText('Advanced'))
407
+ expect(screen.getByText('Montaj Vivid')).toBeTruthy()
408
+ expect(screen.getByText('Neutral brights')).toBeTruthy()
409
+ expect(document.querySelectorAll('img').length).toBe(0)
410
+
411
+ // Still perfectly renderable.
412
+ fireEvent.click(exportButton())
413
+ await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
414
+ })
415
+
416
+ it('never blocks the render when sampling fails', async () => {
417
+ const adapter = pollAdapter()
418
+ adapter.getSampleFrame = vi.fn(async () => { throw new Error('ffmpeg exploded') })
419
+
420
+ render(
421
+ <RenderModal
422
+ adapter={adapter}
423
+ projectId="vid-1"
424
+ onClose={vi.fn()}
425
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
426
+ />,
427
+ )
428
+
429
+ fireEvent.click(screen.getByText('Advanced'))
430
+ await waitFor(() => expect(adapter.getSampleFrame).toHaveBeenCalledTimes(2))
431
+ expect(screen.getByText('Montaj Vivid')).toBeTruthy()
432
+
433
+ fireEvent.click(exportButton())
434
+ await waitFor(() => expect(adapter.renderAsync).toHaveBeenCalledTimes(1))
435
+ })
436
+
437
+ it('samples no curve thumbnails when there are no keeps to sample from', () => {
438
+ const adapter = pollAdapter()
439
+ adapter.getSampleFrame = vi.fn(async () => ({ url: '/x.png' }))
440
+
441
+ render(
442
+ <RenderModal
443
+ adapter={adapter}
444
+ projectId="vid-1"
445
+ onClose={vi.fn()}
446
+ preRenderOptions={{ isHdr: true, keeps: [] }}
447
+ />,
448
+ )
449
+
450
+ // The dialog still opens; opening Advanced shows curve labels but no
451
+ // thumbnail sampling was scheduled (sampleAt is null with no keeps).
452
+ expect(exportHeading()).toBeTruthy()
453
+ fireEvent.click(screen.getByText('Advanced'))
454
+ expect(screen.queryByText('Sampling a frame…')).toBeNull()
455
+ })
456
+ })
457
+
458
+ describe('RenderModal — image color control', () => {
459
+ it('persists a tone choice through the host callback', () => {
460
+ const set = vi.fn()
461
+ render(
462
+ <RenderModal
463
+ adapter={pollAdapter()}
464
+ projectId="vid-1"
465
+ onClose={vi.fn()}
466
+ preRenderOptions={{ isHdr: true, keeps: KEEPS, imageTone: { value: 'vivid', set } }}
467
+ />,
468
+ )
469
+
470
+ // Image color is a first-class expandable section: click its header to open
471
+ // the tone list, then pick another.
472
+ fireEvent.click(screen.getByText('Image color'))
473
+ fireEvent.click(screen.getByText('Broadcast'))
474
+ expect(set).toHaveBeenCalledWith('broadcast')
475
+ })
476
+
477
+ it('omits the tone menu when the host supplies no callback', () => {
478
+ render(
479
+ <RenderModal
480
+ adapter={pollAdapter()}
481
+ projectId="vid-1"
482
+ onClose={vi.fn()}
483
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
484
+ />,
485
+ )
486
+
487
+ // With no imageTone callback the whole Image color control is omitted.
488
+ expect(screen.queryByText('Image color:')).toBeNull()
489
+ })
490
+ })
491
+
492
+ describe('RenderModal — leaving the dialog', () => {
493
+ it('cancels without starting a render', () => {
494
+ const onCancel = vi.fn()
495
+ const onClose = vi.fn()
496
+ const adapter = pollAdapter()
497
+ render(
498
+ <RenderModal
499
+ adapter={adapter}
500
+ projectId="vid-1"
501
+ onClose={onClose}
502
+ onCancel={onCancel}
503
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
504
+ />,
505
+ )
506
+
507
+ fireEvent.click(screen.getByText('Cancel'))
508
+ expect(onCancel).toHaveBeenCalledTimes(1)
509
+ expect(onClose).not.toHaveBeenCalled()
510
+ expect(adapter.renderAsync).not.toHaveBeenCalled()
511
+ })
512
+
513
+ it('closes on Escape, since nothing is running yet', () => {
514
+ const onCancel = vi.fn()
515
+ render(
516
+ <RenderModal
517
+ adapter={pollAdapter()}
518
+ projectId="vid-1"
519
+ onClose={vi.fn()}
520
+ onCancel={onCancel}
521
+ preRenderOptions={{ isHdr: true, keeps: KEEPS }}
522
+ />,
523
+ )
524
+
525
+ fireEvent.keyDown(document, { key: 'Escape' })
526
+ expect(onCancel).toHaveBeenCalledTimes(1)
527
+ })
528
+ })
529
+
530
+ describe('RenderModal — done state outputs', () => {
531
+ function sseAdapter(doneEvent: { type: 'done'; outputPath: string; outputPaths?: string[] }) {
532
+ const a = baseAdapter()
533
+ a.render = vi.fn(async function* () { yield doneEvent })
534
+ return a
535
+ }
536
+
537
+ it('a both export surfaces the derived SDR sibling as its own download', async () => {
538
+ const adapter = sseAdapter({
539
+ type: 'done',
540
+ outputPath: '/ws/p/render/final.mp4',
541
+ outputPaths: ['/ws/p/render/final.mp4', '/ws/p/render/final-sdr.mp4'],
542
+ })
543
+ render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
544
+
545
+ await waitFor(() => expect(screen.getByText('Render complete')).toBeTruthy())
546
+ const master = screen.getByText('Download HDR master') as HTMLAnchorElement
547
+ const sdr = screen.getByText('Download SDR version') as HTMLAnchorElement
548
+ expect(master.getAttribute('href')).toContain('final.mp4')
549
+ expect(sdr.getAttribute('href')).toContain('final-sdr.mp4')
550
+ expect(sdr.getAttribute('download')).toBe('final-sdr.mp4')
551
+ })
552
+
553
+ it('a single-file done event keeps the plain Download button', async () => {
554
+ const adapter = sseAdapter({ type: 'done', outputPath: '/ws/p/render/final.mp4' })
555
+ render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
556
+
557
+ await waitFor(() => expect(screen.getByText('Render complete')).toBeTruthy())
558
+ expect(screen.getByText('Download')).toBeTruthy()
559
+ expect(screen.queryByText('Download SDR version')).toBeNull()
560
+ expect(screen.queryByText('Download HDR master')).toBeNull()
561
+ })
562
+ })
@@ -0,0 +1,65 @@
1
+ /// <reference types="vite/client" />
2
+ /**
3
+ * `parseLogProgress` — the SSE/log-transport render progress estimate. It reads
4
+ * the render's two real running counters (frame-baking jobs and composition
5
+ * segments) so the bar tracks total work. The load-bearing case these pin: the
6
+ * renderer logs `encoded <job> (jobsDone/total done)` after EVERY job, and the
7
+ * "done" in "(N/M done)" is a per-job counter — it must NOT be read as the
8
+ * render finishing, which pinned the bar at ~98% for the entire render.
9
+ */
10
+ import { describe, it, expect } from 'vitest'
11
+ import { parseLogProgress } from '../RenderModal'
12
+
13
+ describe('parseLogProgress', () => {
14
+ it('is null before any line lands (indeterminate bar)', () => {
15
+ expect(parseLogProgress([])).toBeNull()
16
+ })
17
+
18
+ it('does NOT read a per-job "(N/M done)" counter as the render finishing', () => {
19
+ // THE regression: the first "encoded ... (1/12 done)" used to leap the bar to
20
+ // ~98% and freeze it there for the whole render.
21
+ const p = parseLogProgress([
22
+ '[render] rendering captions chunk 1/10 (120 frames)...',
23
+ '[render] encoded captions chunk 1/10 (1/12 done)',
24
+ ])!
25
+ expect(p).toBeLessThan(0.15) // 1 of 12 jobs baked — early, not near-full
26
+ })
27
+
28
+ it('climbs through frame-baking via jobsDone/total', () => {
29
+ const early = parseLogProgress(['[render] encoded overlay-1 (2/12 done)'])!
30
+ const late = parseLogProgress(['[render] encoded captions chunk 10/10 (11/12 done)'])!
31
+ expect(early).toBeGreaterThan(0.05)
32
+ expect(late).toBeGreaterThan(early)
33
+ expect(late).toBeLessThan(0.55) // baking tops at ~0.55, before composition
34
+ })
35
+
36
+ it('climbs through composition via "[montaj compose] segment i/N"', () => {
37
+ const start = parseLogProgress(['[render] composing final video...'])!
38
+ const early = parseLogProgress(['[montaj compose] segment 2/30 (0.1-0.2s): 1 item(s)'])!
39
+ const late = parseLogProgress(['[montaj compose] segment 27/30 (5.0-5.1s): 1 item(s)'])!
40
+ expect(start).toBeGreaterThanOrEqual(0.55)
41
+ expect(early).toBeGreaterThanOrEqual(0.55)
42
+ expect(late).toBeGreaterThan(early)
43
+ expect(late).toBeGreaterThan(0.85) // deep into composition
44
+ })
45
+
46
+ it('is monotonic across a full baking → composition → finalize sequence', () => {
47
+ const seq = [
48
+ '[render] rendering overlay-1 chunk 1/2 (72 frames)...',
49
+ '[render] encoded overlay-1 chunk 1/2 (1/12 done)',
50
+ '[render] encoded captions chunk 5/10 (6/12 done)',
51
+ '[render] encoded captions chunk 10/10 (12/12 done)',
52
+ '[render] composing final video...',
53
+ '[montaj compose] segment 15/30 (2.0s): 1 item(s)',
54
+ '[montaj compose] segment 30/30 (5.0s): 1 item(s)',
55
+ '[montaj compose] concatenating 30 segment(s)...',
56
+ ]
57
+ let prev = 0
58
+ for (let i = 1; i <= seq.length; i++) {
59
+ const p = parseLogProgress(seq.slice(0, i))!
60
+ expect(p).toBeGreaterThanOrEqual(prev)
61
+ prev = p
62
+ }
63
+ expect(prev).toBeGreaterThan(0.85)
64
+ })
65
+ })