@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
@@ -1,6 +1,80 @@
1
- import { useEffect, useRef, useState, type ReactNode } from 'react'
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import type { EditorAdapter, Project, RenderPhase, RenderStatus } from '../types'
3
+ import { ChevronDown, ChevronRight, ChevronLeft, Maximize2 } from 'lucide-react'
4
+ import type {
5
+ EditorAdapter,
6
+ Project,
7
+ RenderExport,
8
+ RenderOptions,
9
+ RenderPhase,
10
+ RenderStatus,
11
+ } from '../types'
12
+ import { IMAGE_TONES, DEFAULT_IMAGE_TONE, type ImageTone } from './imageTone'
13
+ import { TONE_EXAMPLES } from './imageToneExamples'
14
+ import { SDR_CURVES, DEFAULT_SDR_CURVE, honestyLine, type SdrCurve } from './sdrCurves'
15
+ import { Loader } from '../ui/Loader'
16
+
17
+ /**
18
+ * Host-supplied inputs for the pre-render options state. Absent (or `isHdr:
19
+ * false`) → the modal keeps its zero-friction behavior: it fires the render on
20
+ * mount with no inputs at all. Present with `isHdr: true` → it opens on the
21
+ * options panel instead and only renders once the user hits Start render,
22
+ * because an HDR project now has an export decision to make.
23
+ */
24
+ export interface PreRenderOptions {
25
+ /** True when this project renders HDR, which is the only case with a choice. */
26
+ isHdr: boolean
27
+ /**
28
+ * Windows on the project timeline (seconds) the curve thumbnails may sample
29
+ * from — the kept segments of track 0. The modal picks one at random and
30
+ * samples a single timestamp inside it, so both curves are compared on the
31
+ * same frame. Empty/absent → no thumbnails, just labels.
32
+ */
33
+ keeps?: Array<{ start: number; end: number }>
34
+ /**
35
+ * Current HDR image color mapping plus its setter, so the export dialog can
36
+ * surface the same `ImageToneMenu` the toolbar shows. The setter is the
37
+ * host's normal persistence path (an editor mutation that saves and undoes).
38
+ * Absent → the menu is not rendered.
39
+ */
40
+ imageTone?: { value: ImageTone; set: (tone: ImageTone) => void }
41
+ /**
42
+ * Suggested output filename (no extension), seeding the dialog's Name field.
43
+ * Absent → the field defaults to `export`.
44
+ */
45
+ name?: string
46
+ /**
47
+ * Total project-timeline length in seconds — the render's duration. Seeds the
48
+ * dialog's duration footer and bounds the cover-frame slider (0..durationSec).
49
+ * Absent → treated as 0 (footer/slider fall back gracefully).
50
+ */
51
+ durationSec?: number
52
+ /**
53
+ * Output aspect ratio (width / height), so the cover preview matches the
54
+ * project's shape — a vertical project gets a vertical cover. Absent → 16/9.
55
+ */
56
+ aspectRatio?: number
57
+ /**
58
+ * Export resolution control — source-capped tier picker.
59
+ * `value` — the currently-selected [w, h] (initial = the project's current settings.resolution).
60
+ * `available` — the tiers the dialog may offer, each as [w, h] preserving aspect; ordered ascending by short-side.
61
+ * `set` — the host's persistence setter (mutates settings.resolution + saves). Absent → no dropdown rendered.
62
+ */
63
+ resolution?: {
64
+ value: [number, number]
65
+ available: Array<[number, number]>
66
+ set: (r: [number, number]) => void
67
+ }
68
+ /**
69
+ * Export fps control — source-capped tier picker. `value` is the currently-selected fps
70
+ * (initial = settings.fps ?? 30). `available` is the fps tiers offerable (asc). `set` persists.
71
+ */
72
+ fps?: {
73
+ value: number
74
+ available: number[]
75
+ set: (f: number) => void
76
+ }
77
+ }
4
78
 
5
79
  interface RenderModalProps<P extends Project = Project> {
6
80
  projectId: string
@@ -14,6 +88,12 @@ interface RenderModalProps<P extends Project = Project> {
14
88
  * away from the editor — the project is unchanged and the user is likely
15
89
  * about to keep editing. Defaults to onClose if not provided (back-compat). */
16
90
  onCancel?: () => void
91
+ /** Fired exactly once, the moment the render transitions to 'done' (either
92
+ * transport — the poll loop or the SSE stream) — NOT on modal close, and
93
+ * not on error. Callers use this to refresh state that a finished render
94
+ * affects (e.g. re-fetching version history), independent of when/whether
95
+ * the operator dismisses the modal. */
96
+ onRenderComplete?: () => void
17
97
  /** Host-supplied export controls (e.g. a "Download all (.zip)" link) rendered
18
98
  * in the done state's action area, mirroring the carousel render modal. */
19
99
  exportActions?: ReactNode
@@ -27,6 +107,22 @@ interface RenderModalProps<P extends Project = Project> {
27
107
  * Threaded down from the host as a flag (montaj-native → 'logs', Hub clients
28
108
  * → 'phases'), mirroring `VideoEditorProps.renderProgressView`. */
29
109
  progressView?: 'phases' | 'logs'
110
+ /**
111
+ * Opens the modal on a pre-render options panel instead of firing the render
112
+ * on mount. Only HDR projects have anything to choose, so hosts pass this
113
+ * with `isHdr: true` for those and omit it (or pass `isHdr: false`)
114
+ * everywhere else, which preserves the fire-on-mount behavior exactly. */
115
+ preRenderOptions?: PreRenderOptions
116
+ /** Editor theme mode — light/dark. The export dialog and progress/error
117
+ * panels follow `--editor-surface`, so warning/error hues need to darken
118
+ * in light mode. The render-log terminal boxes stay dark by design and so
119
+ * take no mode: they are painted an OPAQUE near-black (`#0a0e17`/`#0e131f`)
120
+ * rather than a translucent `bg-black/40`, which would have composited to
121
+ * mid-grey over a light surface instead of staying a terminal. Those two
122
+ * values are exactly what the old translucent blacks composited to over the
123
+ * dark `--editor-surface`, so dark mode is unchanged. Absent -> dark,
124
+ * matching every existing caller. */
125
+ mode?: 'light' | 'dark'
30
126
  }
31
127
 
32
128
  /** Promoted render output (R2-presigned), as carried by `RenderStatus.media`. */
@@ -34,6 +130,32 @@ type RenderMedia = NonNullable<RenderStatus['media']>[number]
34
130
 
35
131
  function basename(p: string) { return p.split('/').pop() ?? p }
36
132
 
133
+ /**
134
+ * Client mirror of the server's output-name sanitizer
135
+ * (`serve/routes/projects.py` `_sanitize_output_name`): basename, drop the
136
+ * extension and `..`, keep a conservative whitelist, trim leading/trailing
137
+ * space/dot. Falls back to `export` so an empty field is deterministic and the
138
+ * "Save to" line always shows the real filename the render will produce (the
139
+ * server re-sanitizes, so this only needs to match for the common cases).
140
+ */
141
+ export function sanitizeOutputName(name: string): string {
142
+ const base = (name.split(/[/\\]/).pop() ?? '')
143
+ .replace(/\.[^.]*$/, '')
144
+ .replace(/\.\./g, '')
145
+ .replace(/[^A-Za-z0-9 _.-]+/g, '')
146
+ .replace(/^[ .]+|[ .]+$/g, '')
147
+ return base || 'export'
148
+ }
149
+
150
+
151
+ /** mm:ss for a duration/timecode in seconds. Clamps negatives to 0:00. */
152
+ function formatTimecode(sec: number): string {
153
+ const total = Math.max(0, Math.floor(sec))
154
+ const m = Math.floor(total / 60)
155
+ const s = total % 60
156
+ return `${m}:${String(s).padStart(2, '0')}`
157
+ }
158
+
37
159
  // ── Phase model (pure, exported for tests + the stepper) ──────────────────────
38
160
 
39
161
  /**
@@ -45,6 +167,7 @@ export const RENDER_PHASES: RenderPhase[] = [
45
167
  'rendering',
46
168
  'captions',
47
169
  'encoding',
170
+ 'sdr_derive',
48
171
  'saving',
49
172
  'done',
50
173
  ]
@@ -52,12 +175,13 @@ export const RENDER_PHASES: RenderPhase[] = [
52
175
  /** User-facing label for a render phase. Honest, plain-English, no jargon. */
53
176
  export function phaseLabel(phase: RenderPhase): string {
54
177
  switch (phase) {
55
- case 'preparing': return 'Preparing'
56
- case 'rendering': return 'Rendering graphics'
57
- case 'captions': return 'Adding captions'
58
- case 'encoding': return 'Encoding video'
59
- case 'saving': return 'Saving to your library'
60
- case 'done': return 'Done'
178
+ case 'preparing': return 'Preparing'
179
+ case 'rendering': return 'Rendering graphics'
180
+ case 'captions': return 'Adding captions'
181
+ case 'encoding': return 'Encoding video'
182
+ case 'sdr_derive': return 'Deriving SDR'
183
+ case 'saving': return 'Saving to your library'
184
+ case 'done': return 'Done'
61
185
  }
62
186
  }
63
187
 
@@ -66,9 +190,67 @@ export function phaseIndex(phase: RenderPhase): number {
66
190
  return RENDER_PHASES.indexOf(phase)
67
191
  }
68
192
 
69
- /** The five phases shown in the running stepper (terminal `done` excluded). */
193
+ /** The phases shown in the running stepper (terminal `done` excluded). */
70
194
  const STEPPER_PHASES: RenderPhase[] = RENDER_PHASES.filter(p => p !== 'done')
71
195
 
196
+ /**
197
+ * The stepper rows for a given render. `sdr_derive` only runs when an HDR
198
+ * project asked for an SDR file, so listing it on every render would promise a
199
+ * step that never happens.
200
+ */
201
+ export function stepperPhases(opts?: RenderOptions): RenderPhase[] {
202
+ const derives = opts?.export === 'sdr' || opts?.export === 'both'
203
+ return derives ? STEPPER_PHASES : STEPPER_PHASES.filter(p => p !== 'sdr_derive')
204
+ }
205
+
206
+ /**
207
+ * Pick one timestamp (project-timeline seconds) to sample the curve thumbnails
208
+ * at: a random point inside a random keep, kept away from the segment edges so
209
+ * the frame doesn't land on a transition or a black first frame. Returns null
210
+ * when there is nothing worth sampling, which is the caller's signal to render
211
+ * the picker without thumbnails. `rand` is injectable for tests.
212
+ */
213
+ export function pickSampleTime(
214
+ keeps: Array<{ start: number; end: number }> | undefined,
215
+ rand: () => number = Math.random,
216
+ ): number | null {
217
+ const usable = (keeps ?? []).filter(k => k.end - k.start > 0.2)
218
+ if (usable.length === 0) return null
219
+ const keep = usable[Math.min(usable.length - 1, Math.floor(rand() * usable.length))]
220
+ const span = keep.end - keep.start
221
+ // Middle 60% of the keep.
222
+ return keep.start + span * (0.2 + rand() * 0.6)
223
+ }
224
+
225
+ /** The three export choices, in the order they read best: safest first. */
226
+ const EXPORT_CHOICES: Array<{ id: RenderExport; label: string; blurb: string }> = [
227
+ { id: 'auto', label: 'Match footage (HDR)', blurb: 'One HDR file, the way it was shot.' },
228
+ { id: 'sdr', label: 'SDR', blurb: 'One standard file. Plays the same everywhere.' },
229
+ { id: 'both', label: 'Both', blurb: 'The HDR file plus an SDR copy beside it.' },
230
+ ]
231
+
232
+ /** Friendly tier name for a resolution, keyed off the short side (e.g. 1080
233
+ * for both 1080x1920 and 1920x1080). Falls back to "<n>p" for non-standard
234
+ * short sides so an odd source resolution still reads sensibly. */
235
+ export function resLabel([w, h]: [number, number]): string {
236
+ const short = Math.min(w, h)
237
+ switch (short) {
238
+ case 720: return '720p'
239
+ case 1080: return '1080p (HD)'
240
+ case 1440: return '1440p (2K)'
241
+ case 2160: return '2160p (4K)'
242
+ default: return `${short}p`
243
+ }
244
+ }
245
+
246
+ /** Short helper line under an fps tier button — a plain-English hint, empty
247
+ * for the neutral default (30). */
248
+ function fpsHint(fps: number): string {
249
+ if (fps === 24) return 'cinema'
250
+ if (fps === 60) return 'smooth'
251
+ return ''
252
+ }
253
+
72
254
  const POLL_INTERVAL_MS = 2500
73
255
 
74
256
  /**
@@ -83,11 +265,11 @@ const MAX_POLL_FAILURES = 12
83
265
 
84
266
  // ── Stepper ───────────────────────────────────────────────────────────────────
85
267
 
86
- function PhaseStepper({ current }: { current: RenderPhase }) {
268
+ function PhaseStepper({ current, phases }: { current: RenderPhase; phases: RenderPhase[] }) {
87
269
  const currentIdx = phaseIndex(current)
88
270
  return (
89
271
  <div className="flex flex-col gap-3">
90
- {STEPPER_PHASES.map((phase) => {
272
+ {phases.map((phase) => {
91
273
  const idx = phaseIndex(phase)
92
274
  // `done` sits past every stepper phase, so a done status marks them all complete.
93
275
  const complete = idx < currentIdx
@@ -145,18 +327,159 @@ function LogLine({ text }: { text: string }) {
145
327
  )
146
328
  }
147
329
 
148
- export default function RenderModal<P extends Project = Project>({ projectId, adapter, onClose, onCancel, exportActions, progressView }: RenderModalProps<P>) {
330
+ // ── Progress ────────────────────────────────────────────────────────────────
331
+
332
+ /**
333
+ * Best-effort render progress (0..1) for the SSE/log transport, where the phase
334
+ * is pinned to 'rendering' and only log lines arrive. It reads the two phases
335
+ * that carry a real running counter, so the bar tracks TOTAL work rather than
336
+ * leaping on a keyword:
337
+ *
338
+ * • Frame-baking (renderer.js): `encoded <job> (jobsDone/jobs.length done)` —
339
+ * every overlay/caption element (chunked) is one job; jobsDone/total drives
340
+ * 0.05 → 0.55. The "done" in "(N/M done)" is a per-JOB counter, NOT the
341
+ * render finishing — matching it as completion is what pinned the bar at
342
+ * ~98% for the entire render.
343
+ * • Composition (compose.js): `[montaj compose] segment i/N` — drives
344
+ * 0.55 → 0.90.
345
+ *
346
+ * Counter-less markers (composition start, concat, SDR derive, cleanup) nudge
347
+ * the bar between those bands. Monotonic (`Math.max`), so a counter resetting
348
+ * between phases never walks it backward. Returns null before any line lands.
349
+ */
350
+ export function parseLogProgress(logs: string[]): number | null {
351
+ if (logs.length === 0) return null
352
+ let p = 0.02 // a line exists, so the engine has started
353
+ for (const line of logs) {
354
+ // Frame-baking overall counter: "encoded <job> (jobsDone/total done)".
355
+ const baked = line.match(/\((\d+)\s*\/\s*(\d+)\s+done\)/i)
356
+ if (baked) {
357
+ const n = Number(baked[1]), d = Number(baked[2])
358
+ if (d > 0 && n <= d) p = Math.max(p, 0.05 + 0.50 * (n / d))
359
+ continue
360
+ }
361
+ // Composition per-segment counter: "[montaj compose] segment i/N".
362
+ const comp = line.match(/segment\s+(\d+)\s*\/\s*(\d+)/i)
363
+ if (comp && /compos/i.test(line)) {
364
+ const n = Number(comp[1]), d = Number(comp[2])
365
+ if (d > 0 && n <= d) p = Math.max(p, 0.55 + 0.35 * (n / d))
366
+ continue
367
+ }
368
+ // Counter-less phase markers.
369
+ if (/composing final video/i.test(line)) p = Math.max(p, 0.55)
370
+ else if (/concatenat/i.test(line)) p = Math.max(p, 0.90)
371
+ else if (/deriving|\bsdr\b/i.test(line)) p = Math.max(p, 0.92)
372
+ else if (/intermediate files cleaned/i.test(line)) p = Math.max(p, 0.96)
373
+ else if (/rendering .*\bframes\)/i.test(line)) p = Math.max(p, 0.05) // baking started
374
+ }
375
+ return p
376
+ }
377
+
378
+ /**
379
+ * A slim progress bar. A number 0..1 draws a determinate fill; `null` draws an
380
+ * indeterminate sweep (keyframes inlined so the bar is self-contained in any
381
+ * host, no Tailwind config dependency).
382
+ */
383
+ function ProgressBar({ value }: { value: number | null }) {
384
+ const pct = value === null ? null : Math.max(0, Math.min(1, value)) * 100
385
+ return (
386
+ <div className="w-full max-w-xs" role="progressbar" aria-label="Render progress"
387
+ aria-valuenow={pct === null ? undefined : Math.round(pct)} aria-valuemin={0} aria-valuemax={100}>
388
+ <div className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--editor-text)]/10">
389
+ {pct === null ? (
390
+ <>
391
+ <style>{'@keyframes montajRenderIndet{0%{transform:translateX(-100%)}100%{transform:translateX(320%)}}'}</style>
392
+ <div className="h-full w-1/3 rounded-full bg-[var(--editor-accent)]"
393
+ style={{ animation: 'montajRenderIndet 1.2s ease-in-out infinite' }} />
394
+ </>
395
+ ) : (
396
+ <div className="h-full rounded-full bg-[var(--editor-accent)] transition-[width] duration-500 ease-out"
397
+ style={{ width: `${pct}%` }} />
398
+ )}
399
+ </div>
400
+ </div>
401
+ )
402
+ }
403
+
404
+ export default function RenderModal<P extends Project = Project>({ projectId, adapter, onClose, onCancel, onRenderComplete, exportActions, progressView, preRenderOptions, mode = 'dark' }: RenderModalProps<P>) {
149
405
  const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
150
406
  const [phase, setPhase] = useState<RenderPhase>('preparing')
151
407
  const [logs, setLogs] = useState<string[]>([])
152
408
  const [media, setMedia] = useState<RenderMedia[] | null>(null)
153
409
  const [outputPath, setOutput] = useState<string | null>(null)
410
+ // Extra files beyond the primary (a `both` export's derived SDR sibling).
411
+ const [extraOutputs, setExtraOutputs] = useState<string[]>([])
154
412
  const [errorMsg, setError] = useState<string | null>(null)
155
413
  const logRef = useRef<HTMLDivElement>(null)
156
414
  const cancelledRef = useRef(false)
157
415
  const unmountedRef = useRef(false)
158
416
  const cleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
159
417
  const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
418
+ // Guards onRenderComplete so it fires exactly once per render attempt even
419
+ // though the poll loop can tick again after 'done' (see the note at
420
+ // `pollTimerRef.current = setInterval(...)` below) and even across both
421
+ // completion transports (poll vs. SSE).
422
+ const renderCompleteFiredRef = useRef(false)
423
+
424
+ // ── Pre-render options (the Export dialog) ─────────────────────────────────
425
+ // `started` gates the render effect. Hosts that pass `preRenderOptions` open
426
+ // on the dialog (montaj always does — for HDR and SDR alike); hosts that omit
427
+ // it keep the historical fire-on-mount behavior exactly. The HDR-only controls
428
+ // (Format / Image color / SDR curve) stay gated on `isHdr` inside the dialog.
429
+ const showOptions = !!preRenderOptions
430
+ const isHdr = !!preRenderOptions?.isHdr
431
+ const durationSec = preRenderOptions?.durationSec ?? 0
432
+ // Cover preview follows the project's shape. Portrait projects constrain by
433
+ // height (so a tall cover can't overrun the dialog); landscape fills the column.
434
+ const coverAspect = preRenderOptions?.aspectRatio && preRenderOptions.aspectRatio > 0
435
+ ? preRenderOptions.aspectRatio
436
+ : 16 / 9
437
+ const coverPortrait = coverAspect < 1
438
+ const [started, setStarted] = useState(!showOptions)
439
+ const [exportChoice, setExport] = useState<RenderExport>('auto')
440
+ const [sdrCurve, setSdrCurve] = useState<SdrCurve>(DEFAULT_SDR_CURVE)
441
+ const [advancedOpen, setAdvanced] = useState(false)
442
+ const [imageColorOpen, setImageColor] = useState(false)
443
+ const [showLog, setShowLog] = useState(false)
444
+ const [thumbs, setThumbs] = useState<Record<string, string>>({})
445
+ const [thumbsPending, setPending] = useState(false)
446
+ // The SDR curve whose thumbnail is opened full-size in the lightbox (see
447
+ // below), so the small grid preview can be inspected at detail and paged
448
+ // between. Null = closed.
449
+ const [expandedCurveId, setExpandedCurveId] = useState<string | null>(null)
450
+ // Chosen once per mount so both curves are sampled on the same frame. HDR-only
451
+ // — the curve compare (and thus this sample) doesn't exist for SDR projects.
452
+ const [sampleAt] = useState<number | null>(() =>
453
+ isHdr ? pickSampleTime(preRenderOptions?.keeps) : null)
454
+ const thumbsRequestedRef = useRef(false)
455
+
456
+ // ── Name + cover (every project) ────────────────────────────────────────────
457
+ // Name seeds the output filename; cover is the poster-frame timecode (project
458
+ // seconds). Cover defaults to a sampled keep frame, or the timeline midpoint.
459
+ const [name, setName] = useState<string>(preRenderOptions?.name ?? 'export')
460
+ const [coverTime, setCoverTime] = useState<number>(() => {
461
+ const picked = pickSampleTime(preRenderOptions?.keeps)
462
+ return picked ?? (durationSec > 0 ? durationSec / 2 : 0)
463
+ })
464
+ const [coverEditing, setCoverEditing] = useState(false)
465
+ const [coverUrl, setCoverUrl] = useState<string | null>(null)
466
+ const [coverLoading, setCoverLoading] = useState(false)
467
+ // "Edit cover" frame picker: evenly-spaced sample times (roughly one per 4s,
468
+ // clamped — each tile is a full composited sample_frame render, so the grid is
469
+ // bounded and loaded lazily). `coverTiles` maps tile index → resolved URL.
470
+ const [coverTiles, setCoverTiles] = useState<Record<number, string>>({})
471
+ const coverTileTimes = useMemo<number[]>(() => {
472
+ if (durationSec <= 0) return []
473
+ const n = 10 // fixed grid of 10 evenly-spaced frames (was ~one per 4s)
474
+ return Array.from({ length: n }, (_, i) => ((i + 0.5) / n) * durationSec)
475
+ }, [durationSec])
476
+ const coverTilesRequestedRef = useRef<string>('')
477
+
478
+ // The options the render was started with. Read through a ref inside the
479
+ // render effect (which must not re-fire when unrelated state changes) and
480
+ // held in state for the stepper, which needs to re-render on it.
481
+ const [renderOpts, setRenderOpts] = useState<RenderOptions | undefined>(undefined)
482
+ const renderOptsRef = useRef<RenderOptions | undefined>(undefined)
160
483
 
161
484
  // `usePolling` is the data-transport decision (does this adapter expose the
162
485
  // poll-based render API?) — independent of which progress UI we show.
@@ -166,7 +489,97 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
166
489
  // 'logs' only renders content on the SSE transport, which streams log lines.
167
490
  const view = progressView ?? 'phases'
168
491
 
492
+ // Curve thumbnails: one sample of the SAME frame per curve, fetched in
493
+ // parallel and folded in as they land. Deliberately fire-and-forget — the
494
+ // options UI is already on screen, an absent `getSampleFrame` or a failed
495
+ // call just leaves the picker showing labels, and nothing here can block or
496
+ // fail the render.
497
+ useEffect(() => {
498
+ // Deferred until Advanced is actually open: the curve thumbnails only show
499
+ // inside that (collapsed-by-default) disclosure, so sampling them on mount
500
+ // spent two full frame renders on previews most exports never look at. Now
501
+ // the modal opens sampling just the cover; the curves sample the first time
502
+ // Advanced is expanded (once — `thumbsRequestedRef` dedups).
503
+ if (started || sampleAt === null || !advancedOpen) return
504
+ const getSampleFrame = adapter.getSampleFrame
505
+ if (!getSampleFrame) return
506
+ // Fetch each curve's thumbnail once. The ref guard dedups StrictMode's
507
+ // mount→cleanup→mount so dev doesn't sample every frame twice.
508
+ //
509
+ // Delivery is deliberately NOT gated on a per-invocation `alive` flag. Under
510
+ // StrictMode mount #1 fires the fetches and its cleanup would flip that flag
511
+ // to false, while mount #2 hits this dedup guard and returns without ever
512
+ // re-arming it — so the in-flight results land in a dead closure and no
513
+ // thumbnail ever paints (dev-only; production has no double-invoke). Because
514
+ // setThumbs/setPending are functional-merge updates they are safe to call
515
+ // from whichever mount owns the fetches; on a genuine unmount React 18 makes
516
+ // the setState a harmless no-op. So we just deliver.
517
+ if (thumbsRequestedRef.current) return
518
+ thumbsRequestedRef.current = true
519
+
520
+ let outstanding = SDR_CURVES.length
521
+ setPending(true)
522
+ const settle = () => { if (--outstanding === 0) setPending(false) }
523
+
524
+ for (const curve of SDR_CURVES) {
525
+ getSampleFrame(projectId, sampleAt, { sdrCurve: curve.id })
526
+ .then(({ url }) => setThumbs(t => ({ ...t, [curve.id]: url })))
527
+ .catch(() => {})
528
+ .finally(settle)
529
+ }
530
+ }, [started, sampleAt, adapter, projectId, advancedOpen])
531
+
532
+ // Cover preview: sample the poster frame at `coverTime`, DEBOUNCED so dragging
533
+ // the slider doesn't spam the sampler. Fire-and-forget like the curve
534
+ // thumbnails — an absent `getSampleFrame` or a failed call just leaves the
535
+ // placeholder, and nothing here can block or fail the render. The `alive`
536
+ // flag + clearTimeout drop a stale drag's in-flight request (and make the
537
+ // debounce naturally StrictMode-safe: the first mount's timer is cleared
538
+ // before it can fire).
169
539
  useEffect(() => {
540
+ if (started) return
541
+ const getSampleFrame = adapter.getSampleFrame
542
+ if (!getSampleFrame) return
543
+ let alive = true
544
+ setCoverLoading(true)
545
+ const timer = setTimeout(() => {
546
+ // Sample from the SDR proxy — the cover is a poster preview, not a
547
+ // colour-graded frame, so it doesn't need the master or a per-curve tone
548
+ // map (the real poster is made from the master at export). Much faster,
549
+ // and it means dragging the cover slider no longer re-samples on curve.
550
+ getSampleFrame(projectId, coverTime, { preferProxy: true })
551
+ .then(({ url }) => { if (alive) setCoverUrl(url) })
552
+ .catch(() => {})
553
+ .finally(() => { if (alive) setCoverLoading(false) })
554
+ }, 200)
555
+ return () => { alive = false; clearTimeout(timer) }
556
+ }, [started, coverTime, adapter, projectId])
557
+
558
+ // Cover frame grid: sampled lazily once the picker opens, one composited frame
559
+ // per tile from the SDR proxy (fast; these are only for picking a timestamp),
560
+ // folded in as each lands. Keyed by tile count so re-opening the picker reuses
561
+ // what's cached. Fire-and-forget — a failed/absent sampler just leaves that
562
+ // tile on its loader.
563
+ useEffect(() => {
564
+ if (started || !coverEditing) return
565
+ const getSampleFrame = adapter.getSampleFrame
566
+ if (!getSampleFrame || coverTileTimes.length === 0) return
567
+ const key = `${coverTileTimes.length}`
568
+ if (coverTilesRequestedRef.current === key) return
569
+ coverTilesRequestedRef.current = key
570
+ setCoverTiles({})
571
+ let alive = true
572
+ coverTileTimes.forEach((t, i) => {
573
+ getSampleFrame(projectId, t, { preferProxy: true })
574
+ .then(({ url }) => { if (alive) setCoverTiles(prev => ({ ...prev, [i]: url })) })
575
+ .catch(() => {})
576
+ })
577
+ return () => { alive = false }
578
+ }, [started, coverEditing, coverTileTimes, adapter, projectId])
579
+
580
+ useEffect(() => {
581
+ if (!started) return
582
+
170
583
  // React StrictMode in dev fires mount → cleanup → mount synchronously to
171
584
  // catch effects that aren't idempotent. Triggering a render is the textbook
172
585
  // non-idempotent effect (spawns a subprocess), so we have to handle it
@@ -186,12 +599,13 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
186
599
 
187
600
  unmountedRef.current = false
188
601
  cancelledRef.current = false
602
+ renderCompleteFiredRef.current = false
189
603
 
190
604
  void (async () => {
191
605
  try {
192
606
  if (usePolling) {
193
607
  // Async, poll-based render: kick once, then poll status until terminal.
194
- await adapter.renderAsync!(projectId)
608
+ await adapter.renderAsync!(projectId, renderOptsRef.current)
195
609
  if (unmountedRef.current || cancelledRef.current) return
196
610
 
197
611
  let pollFailures = 0
@@ -227,6 +641,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
227
641
  stopPolling()
228
642
  setMedia(snap.media ?? null)
229
643
  setStatus('done')
644
+ fireRenderComplete()
230
645
  } else if (snap.status === 'error') {
231
646
  stopPolling()
232
647
  setError(snap.error ?? 'Render failed.')
@@ -247,7 +662,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
247
662
  pollTimerRef.current = setInterval(() => { void tick() }, POLL_INTERVAL_MS)
248
663
  } else {
249
664
  // Fallback for older hosts: consume the SSE render stream.
250
- for await (const ev of adapter.render(projectId)) {
665
+ for await (const ev of adapter.render(projectId, renderOptsRef.current)) {
251
666
  if (unmountedRef.current || cancelledRef.current) break
252
667
  if (ev.type === 'log') {
253
668
  // Streaming hosts have no phase signal. Accumulate the line for
@@ -257,7 +672,9 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
257
672
  setPhase('rendering')
258
673
  } else if (ev.type === 'done') {
259
674
  setOutput(ev.outputPath)
675
+ setExtraOutputs(ev.outputPaths?.slice(1) ?? [])
260
676
  setStatus('done')
677
+ fireRenderComplete()
261
678
  } else {
262
679
  setError(ev.message)
263
680
  setStatus('error')
@@ -281,6 +698,17 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
281
698
  }
282
699
  }
283
700
 
701
+ // See `renderCompleteFiredRef`'s declaration: guards against firing more
702
+ // than once for a single render attempt — both because the poll loop can
703
+ // tick again after 'done' (a stray `setInterval` re-arm right after the
704
+ // immediate first tick already resolved) and because callers only care
705
+ // about the transition into 'done', not every render that reaches it.
706
+ function fireRenderComplete() {
707
+ if (renderCompleteFiredRef.current) return
708
+ renderCompleteFiredRef.current = true
709
+ onRenderComplete?.()
710
+ }
711
+
284
712
  function scheduleCleanup() {
285
713
  // Defer the actual teardown. StrictMode's transient unmount fires before
286
714
  // the next mount; setTimeout(0) puts the teardown after both, giving the
@@ -292,21 +720,39 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
292
720
  stopPolling()
293
721
  }, 0)
294
722
  }
295
- }, [projectId, adapter])
723
+ }, [projectId, adapter, started])
296
724
 
297
725
  // Auto-scroll the log panel as lines stream in.
298
726
  useEffect(() => {
299
727
  if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
300
728
  }, [logs])
301
729
 
302
- // Escape to close only when done/error
730
+ // Escape to close when done/error, and from the options panel — nothing has
731
+ // been started there, so backing out is free.
303
732
  useEffect(() => {
304
733
  const onKey = (e: KeyboardEvent) => {
305
- if (e.key === 'Escape' && status !== 'running') onClose()
734
+ // Lightbox open: Escape closes it, arrows page between the loaded curve
735
+ // previews. It captures these keys so they don't also close the dialog.
736
+ if (expandedCurveId) {
737
+ if (e.key === 'Escape') { setExpandedCurveId(null); return }
738
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
739
+ e.preventDefault()
740
+ const avail = SDR_CURVES.filter(c => thumbs[c.id])
741
+ if (avail.length > 1) {
742
+ const cur = avail.findIndex(c => c.id === expandedCurveId)
743
+ const step = e.key === 'ArrowRight' ? 1 : -1
744
+ setExpandedCurveId(avail[(cur + step + avail.length) % avail.length].id)
745
+ }
746
+ }
747
+ return
748
+ }
749
+ if (e.key !== 'Escape') return
750
+ if (!started) (onCancel ?? onClose)()
751
+ else if (status !== 'running') onClose()
306
752
  }
307
753
  document.addEventListener('keydown', onKey)
308
754
  return () => document.removeEventListener('keydown', onKey)
309
- }, [status, onClose])
755
+ }, [status, started, onClose, onCancel, expandedCurveId, thumbs])
310
756
 
311
757
  function handleCancel() {
312
758
  cancelledRef.current = true
@@ -317,6 +763,474 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
317
763
  ;(onCancel ?? onClose)()
318
764
  }
319
765
 
766
+ function handleStart() {
767
+ const opts: RenderOptions = {
768
+ export: exportChoice,
769
+ sdrCurve,
770
+ name: sanitizeOutputName(name),
771
+ cover: coverTime,
772
+ }
773
+ // The ref is what the render effect reads (it must not re-fire on unrelated
774
+ // state); the state copy drives the stepper's phase list.
775
+ renderOptsRef.current = opts
776
+ setRenderOpts(opts)
777
+ setStarted(true)
778
+ }
779
+
780
+ // ── Export dialog ───────────────────────────────────────────────────────────
781
+ if (!started) {
782
+ const curveInfo = SDR_CURVES.find(c => c.id === sdrCurve) ?? SDR_CURVES[0]
783
+ const outputName = sanitizeOutputName(name)
784
+ // Lightbox nav: only curves whose thumbnail has loaded are pageable.
785
+ const expandedCurve = expandedCurveId ? SDR_CURVES.find(c => c.id === expandedCurveId) ?? null : null
786
+ const expandedUrl = expandedCurveId ? thumbs[expandedCurveId] : undefined
787
+ const navCurves = SDR_CURVES.filter(c => thumbs[c.id])
788
+ const canNavCurves = navCurves.length > 1
789
+ const goCurve = (step: number) => {
790
+ const cur = navCurves.findIndex(c => c.id === expandedCurveId)
791
+ if (cur >= 0) setExpandedCurveId(navCurves[(cur + step + navCurves.length) % navCurves.length].id)
792
+ }
793
+ return (<>
794
+ {createPortal(
795
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4">
796
+ <div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-white/10 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
797
+
798
+ {/* Header */}
799
+ <div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
800
+ <h2 className="text-sm font-semibold text-[var(--editor-text)]">Export</h2>
801
+ <button
802
+ onClick={handleCancel}
803
+ aria-label="Close"
804
+ className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none"
805
+ >
806
+ ×
807
+ </button>
808
+ </div>
809
+
810
+ {/* Two-column body: cover on the left, settings on the right. */}
811
+ <div className="flex flex-col md:flex-row gap-5 px-5 py-4 max-h-[70vh] overflow-y-auto">
812
+
813
+ {/* Left — cover preview + editor */}
814
+ <div className="md:w-64 shrink-0 flex flex-col gap-2">
815
+ <div
816
+ className="relative mx-auto max-w-full overflow-hidden rounded-lg border border-white/10 bg-black/40 flex items-center justify-center"
817
+ style={coverPortrait
818
+ ? { aspectRatio: coverAspect, height: 'min(46vh, 22rem)' }
819
+ : { aspectRatio: coverAspect, width: '100%' }}
820
+ >
821
+ {coverUrl ? (
822
+ <img
823
+ src={coverUrl}
824
+ alt="Cover frame"
825
+ className="w-full h-full object-cover"
826
+ />
827
+ ) : coverLoading ? (
828
+ <Loader size="md" label="Loading cover" />
829
+ ) : (
830
+ <span className="text-[11px] text-[var(--editor-text)]/40">No preview</span>
831
+ )}
832
+ </div>
833
+ {adapter.getSampleFrame && (
834
+ <>
835
+ <button
836
+ onClick={() => setCoverEditing(o => !o)}
837
+ aria-expanded={coverEditing}
838
+ className="self-start text-xs text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] transition-colors"
839
+ >
840
+ {coverEditing ? 'Done' : 'Edit cover'}
841
+ </button>
842
+ {coverEditing && (
843
+ coverTileTimes.length > 0 ? (
844
+ <div className="flex gap-1.5 overflow-x-auto pb-1" role="radiogroup" aria-label="Cover frame">
845
+ {coverTileTimes.map((t, i) => {
846
+ const url = coverTiles[i]
847
+ const nearest = coverTileTimes.reduce((best, tt, j) =>
848
+ Math.abs(tt - coverTime) < Math.abs(coverTileTimes[best] - coverTime) ? j : best, 0)
849
+ const active = i === nearest
850
+ return (
851
+ <button
852
+ key={i}
853
+ role="radio"
854
+ aria-checked={active}
855
+ onClick={() => setCoverTime(t)}
856
+ title={formatTimecode(t)}
857
+ className={`relative shrink-0 overflow-hidden rounded border transition-colors ${
858
+ active
859
+ ? 'border-[var(--editor-accent)] ring-1 ring-[var(--editor-accent)]'
860
+ : 'border-white/10 hover:border-white/40'
861
+ }`}
862
+ style={{ aspectRatio: coverAspect, height: 72 }}
863
+ >
864
+ {url
865
+ ? <img src={url} alt="" className="h-full w-full object-cover" />
866
+ : <span className="flex h-full w-full items-center justify-center bg-black/40"><Loader size="sm" /></span>}
867
+ </button>
868
+ )
869
+ })}
870
+ </div>
871
+ ) : (
872
+ <p className="text-[11px] text-[var(--editor-text)]/40">No frames to pick from.</p>
873
+ )
874
+ )}
875
+ </>
876
+ )}
877
+ </div>
878
+
879
+ {/* Right — settings */}
880
+ <div className="flex-1 min-w-0 flex flex-col gap-4">
881
+ {/* Name */}
882
+ <label className="flex flex-col gap-1.5">
883
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Name</span>
884
+ <input
885
+ type="text"
886
+ value={name}
887
+ onChange={(e) => setName(e.target.value)}
888
+ placeholder="export"
889
+ className="text-sm px-3 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] transition-colors"
890
+ />
891
+ </label>
892
+
893
+ {/* Save to */}
894
+ <div className="flex flex-col gap-1">
895
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Save to</span>
896
+ <span className="text-xs font-mono text-[var(--editor-text)]/70 truncate">output/{outputName}.mp4</span>
897
+ <span className="text-[11px] text-[var(--editor-text)]/45">Download after export.</span>
898
+ </div>
899
+
900
+ {/* Resolution — source-capped tier picker. Only rendered when the
901
+ host supplies a `resolution` control; the tile whose short
902
+ side equals the CURRENT project resolution is pre-selected.
903
+ That selected state is the only affordance — no "recommended"
904
+ badge (per operator: the pre-selection is enough, and the
905
+ badge overflowed the tile). */}
906
+ {preRenderOptions?.resolution && (() => {
907
+ const res = preRenderOptions.resolution
908
+ const currentShort = Math.min(...res.value)
909
+ return (
910
+ <div className="flex flex-col gap-2">
911
+ <p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Resolution</p>
912
+ <div role="radiogroup" aria-label="Resolution" className="grid grid-cols-2 sm:grid-cols-4 gap-2">
913
+ {res.available.map(([w, h]) => {
914
+ const short = Math.min(w, h)
915
+ const active = short === currentShort
916
+ return (
917
+ <button
918
+ key={`${w}x${h}`}
919
+ role="radio"
920
+ aria-checked={active}
921
+ onClick={() => res.set([w, h])}
922
+ className={`flex flex-col gap-1 rounded-lg border p-2.5 text-left transition-colors ${
923
+ active
924
+ ? 'border-violet-400/60 bg-violet-400/10'
925
+ : 'border-[var(--editor-border)] hover:bg-[var(--editor-text)]/5'
926
+ }`}
927
+ >
928
+ <span className="text-xs font-semibold text-[var(--editor-text)]">{resLabel([w, h])}</span>
929
+ <span className="text-[10px] leading-snug text-[var(--editor-text)]/55">{w} × {h}</span>
930
+ </button>
931
+ )
932
+ })}
933
+ </div>
934
+ </div>
935
+ )
936
+ })()}
937
+
938
+ {/* Frame rate — source-capped tier picker, same idiom as Resolution. */}
939
+ {preRenderOptions?.fps && (() => {
940
+ const fps = preRenderOptions.fps
941
+ return (
942
+ <div className="flex flex-col gap-2">
943
+ <p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Frame rate</p>
944
+ <div role="radiogroup" aria-label="Frame rate" className="grid grid-cols-3 gap-2">
945
+ {fps.available.map(f => {
946
+ const active = f === fps.value
947
+ const hint = fpsHint(f)
948
+ return (
949
+ <button
950
+ key={f}
951
+ role="radio"
952
+ aria-checked={active}
953
+ onClick={() => fps.set(f)}
954
+ className={`flex flex-col gap-1 rounded-lg border p-2.5 text-left transition-colors ${
955
+ active
956
+ ? 'border-violet-400/60 bg-violet-400/10'
957
+ : 'border-[var(--editor-border)] hover:bg-[var(--editor-text)]/5'
958
+ }`}
959
+ >
960
+ <span className="text-xs font-semibold text-[var(--editor-text)]">{f} fps</span>
961
+ <span className="text-[10px] leading-snug text-[var(--editor-text)]/55">{hint}</span>
962
+ </button>
963
+ )
964
+ })}
965
+ </div>
966
+ </div>
967
+ )
968
+ })()}
969
+
970
+ {/* HDR-only controls: export format, image color, tone-curve compare. */}
971
+ {isHdr && (
972
+ <>
973
+ {/* Format */}
974
+ <div className="flex flex-col gap-2">
975
+ <p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">Format</p>
976
+ <div role="radiogroup" aria-label="Format" className="grid grid-cols-3 gap-2">
977
+ {EXPORT_CHOICES.map(choice => {
978
+ const active = choice.id === exportChoice
979
+ return (
980
+ <button
981
+ key={choice.id}
982
+ role="radio"
983
+ aria-checked={active}
984
+ onClick={() => setExport(choice.id)}
985
+ className={`flex flex-col gap-1 rounded-lg border p-2.5 text-left transition-colors ${
986
+ active
987
+ ? 'border-violet-400/60 bg-violet-400/10'
988
+ : 'border-[var(--editor-border)] hover:bg-[var(--editor-text)]/5'
989
+ }`}
990
+ >
991
+ <span className="text-xs font-semibold text-[var(--editor-text)]">{choice.label}</span>
992
+ <span className="text-[10px] leading-snug text-[var(--editor-text)]/55">{choice.blurb}</span>
993
+ </button>
994
+ )
995
+ })}
996
+ </div>
997
+ </div>
998
+
999
+ {/* Image color — a disclosure matching the Advanced one below: a
1000
+ plain chevron toggle opening a 2-col grid of tones, each with a
1001
+ per-tone example image. Persists through the host's normal
1002
+ settings path (an editor mutation that saves + undoes). */}
1003
+ {(() => {
1004
+ const it = preRenderOptions?.imageTone
1005
+ if (!it) return null
1006
+ const tone = it.value ?? DEFAULT_IMAGE_TONE
1007
+ const toneInfo = IMAGE_TONES.find(t => t.id === tone) ?? IMAGE_TONES[0]
1008
+ return (
1009
+ <div className="flex flex-col gap-3 border-t border-[var(--editor-border)] pt-3">
1010
+ <button
1011
+ onClick={() => setImageColor(o => !o)}
1012
+ aria-expanded={imageColorOpen}
1013
+ className="flex items-center gap-1.5 self-start text-xs text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] transition-colors"
1014
+ >
1015
+ {imageColorOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
1016
+ Image color
1017
+ </button>
1018
+
1019
+ {imageColorOpen ? (
1020
+ <div className="flex flex-col gap-2">
1021
+ <p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">
1022
+ How photos and logos convert for HDR
1023
+ </p>
1024
+ <div role="radiogroup" aria-label="Image color" className="grid grid-cols-2 gap-2">
1025
+ {IMAGE_TONES.map(t => {
1026
+ const active = t.id === tone
1027
+ return (
1028
+ <button
1029
+ key={t.id}
1030
+ role="radio"
1031
+ aria-checked={active}
1032
+ onClick={() => it.set(t.id)}
1033
+ className={`flex flex-col gap-1.5 rounded-lg border p-2 text-left transition-colors ${
1034
+ active
1035
+ ? 'border-violet-400/60 bg-violet-400/10'
1036
+ : 'border-[var(--editor-border)] hover:bg-[var(--editor-text)]/5'
1037
+ }`}
1038
+ >
1039
+ <img
1040
+ src={TONE_EXAMPLES[t.id]}
1041
+ alt={`${t.label} example`}
1042
+ className="w-full aspect-video rounded object-cover border border-[var(--editor-border)]"
1043
+ />
1044
+ <span className="text-xs font-semibold text-[var(--editor-text)] flex items-center gap-1.5">
1045
+ {t.label}
1046
+ {t.id === DEFAULT_IMAGE_TONE && (
1047
+ <span className="text-[9px] font-normal px-1 py-px rounded bg-[var(--editor-text)]/10 text-[var(--editor-text)]/55">default</span>
1048
+ )}
1049
+ </span>
1050
+ <span className="text-[10px] leading-snug text-[var(--editor-text)]/60">{t.summary}</span>
1051
+ </button>
1052
+ )
1053
+ })}
1054
+ </div>
1055
+ </div>
1056
+ ) : (
1057
+ <p className="text-[11px] text-[var(--editor-text)]/45">
1058
+ Photos and logos use the {toneInfo.label} conversion. Open to change.
1059
+ </p>
1060
+ )}
1061
+ </div>
1062
+ )
1063
+ })()}
1064
+
1065
+ {/* Advanced disclosure — SDR tone-curve compare. */}
1066
+ <div className="flex flex-col gap-3 border-t border-[var(--editor-border)] pt-3">
1067
+ <button
1068
+ onClick={() => setAdvanced(o => !o)}
1069
+ aria-expanded={advancedOpen}
1070
+ className="flex items-center gap-1.5 self-start text-xs text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] transition-colors"
1071
+ >
1072
+ {advancedOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
1073
+ Advanced
1074
+ </button>
1075
+
1076
+ {advancedOpen ? (
1077
+ <div className="flex flex-col gap-2">
1078
+ <p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">
1079
+ Tone curve for SDR
1080
+ </p>
1081
+ <div role="radiogroup" aria-label="Tone curve for SDR" className="grid grid-cols-2 gap-2">
1082
+ {SDR_CURVES.map(curve => {
1083
+ const active = curve.id === sdrCurve
1084
+ const thumb = thumbs[curve.id]
1085
+ return (
1086
+ <button
1087
+ key={curve.id}
1088
+ role="radio"
1089
+ aria-checked={active}
1090
+ onClick={() => setSdrCurve(curve.id)}
1091
+ className={`flex flex-col gap-1.5 rounded-lg border p-2 text-left transition-colors ${
1092
+ active
1093
+ ? 'border-violet-400/60 bg-violet-400/10'
1094
+ : 'border-[var(--editor-border)] hover:bg-[var(--editor-text)]/5'
1095
+ }`}
1096
+ >
1097
+ {thumb ? (
1098
+ // Shown at the SOURCE aspect ratio (not a fixed
1099
+ // 16:9) so a vertical project reads as vertical.
1100
+ // The hover "expand" control opens it full-size
1101
+ // in the lightbox; it stops propagation so a
1102
+ // click on it inspects rather than selects the
1103
+ // curve.
1104
+ <span className="relative block w-full">
1105
+ <img
1106
+ src={thumb}
1107
+ alt={`${curve.label} sample frame`}
1108
+ style={{ aspectRatio: coverAspect }}
1109
+ className="w-full rounded object-cover border border-[var(--editor-border)]"
1110
+ />
1111
+ <span
1112
+ role="button"
1113
+ tabIndex={0}
1114
+ aria-label={`Expand ${curve.label} preview`}
1115
+ onClick={(e) => { e.stopPropagation(); setExpandedCurveId(curve.id) }}
1116
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); setExpandedCurveId(curve.id) } }}
1117
+ className="absolute top-1 right-1 flex items-center justify-center rounded bg-black/55 p-1 text-white/85 hover:text-white cursor-pointer transition-colors"
1118
+ >
1119
+ <Maximize2 size={12} />
1120
+ </span>
1121
+ </span>
1122
+ ) : thumbsPending ? (
1123
+ <span style={{ aspectRatio: coverAspect }} className="w-full rounded border border-[var(--editor-border)] bg-[var(--editor-text)]/5 flex items-center justify-center">
1124
+ <Loader size="sm" />
1125
+ </span>
1126
+ ) : null}
1127
+ <span className="text-xs font-semibold text-[var(--editor-text)] flex items-center gap-1.5">
1128
+ {curve.label}
1129
+ {curve.id === DEFAULT_SDR_CURVE && (
1130
+ <span className="text-[9px] font-normal px-1 py-px rounded bg-[var(--editor-text)]/10 text-[var(--editor-text)]/55">default</span>
1131
+ )}
1132
+ </span>
1133
+ <span className="text-[10px] leading-snug text-[var(--editor-text)]/60">{curve.blurb}</span>
1134
+ </button>
1135
+ )
1136
+ })}
1137
+ </div>
1138
+ <p
1139
+ data-testid="sdr-honesty-line"
1140
+ className={`text-[11px] leading-snug ${
1141
+ sdrCurve === DEFAULT_SDR_CURVE
1142
+ ? 'text-[var(--editor-text)]/55'
1143
+ : (mode === 'light' ? 'text-amber-700' : 'text-amber-400/90')
1144
+ }`}
1145
+ >
1146
+ {honestyLine(sdrCurve)}
1147
+ </p>
1148
+ </div>
1149
+ ) : (
1150
+ <p className="text-[11px] text-[var(--editor-text)]/45">
1151
+ SDR files use the {curveInfo.label} look. Open Advanced to compare curves.
1152
+ </p>
1153
+ )}
1154
+ </div>
1155
+ </>
1156
+ )}
1157
+ </div>
1158
+ </div>
1159
+
1160
+ {/* Footer — duration on the left, actions on the right. */}
1161
+ <div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
1162
+ <span className="text-xs text-[var(--editor-text)]/50 tabular-nums">
1163
+ {durationSec > 0 ? formatTimecode(durationSec) : ''}
1164
+ </span>
1165
+ <div className="flex items-center gap-2">
1166
+ <button
1167
+ onClick={handleCancel}
1168
+ className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:opacity-90 transition-colors"
1169
+ >
1170
+ Cancel
1171
+ </button>
1172
+ <button
1173
+ onClick={handleStart}
1174
+ className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] font-medium hover:opacity-90 transition-colors"
1175
+ >
1176
+ Export
1177
+ </button>
1178
+ </div>
1179
+ </div>
1180
+ </div>
1181
+ </div>,
1182
+ document.body,
1183
+ )}
1184
+ {expandedCurve && expandedUrl && createPortal(
1185
+ <div
1186
+ className="fixed inset-0 z-[60] flex flex-col items-center justify-center gap-3 bg-black/90 p-6"
1187
+ onClick={() => setExpandedCurveId(null)}
1188
+ role="dialog"
1189
+ aria-label={`${expandedCurve.label} preview`}
1190
+ >
1191
+ <div className="relative flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
1192
+ {canNavCurves && (
1193
+ <button
1194
+ onClick={() => goCurve(-1)}
1195
+ aria-label="Previous curve"
1196
+ className="absolute -left-3 sm:-left-14 top-1/2 -translate-y-1/2 rounded-full bg-black/60 p-2 text-white/80 hover:text-white transition-colors"
1197
+ >
1198
+ <ChevronLeft size={22} />
1199
+ </button>
1200
+ )}
1201
+ {/* Clicking the image itself pages to the next curve (wraps) — the
1202
+ quickest A/B compare; the backdrop or Esc closes. */}
1203
+ <img
1204
+ src={expandedUrl}
1205
+ alt={`${expandedCurve.label} sample frame`}
1206
+ onClick={() => { if (canNavCurves) goCurve(1) }}
1207
+ className={`max-h-[82vh] max-w-[88vw] rounded-lg object-contain shadow-2xl ${canNavCurves ? 'cursor-pointer' : ''}`}
1208
+ />
1209
+ {canNavCurves && (
1210
+ <button
1211
+ onClick={() => goCurve(1)}
1212
+ aria-label="Next curve"
1213
+ className="absolute -right-3 sm:-right-14 top-1/2 -translate-y-1/2 rounded-full bg-black/60 p-2 text-white/80 hover:text-white transition-colors"
1214
+ >
1215
+ <ChevronRight size={22} />
1216
+ </button>
1217
+ )}
1218
+ </div>
1219
+ <div className="flex items-center gap-2 text-xs" onClick={(e) => e.stopPropagation()}>
1220
+ <span className="font-semibold text-white/90">{expandedCurve.label}</span>
1221
+ {expandedCurve.id === DEFAULT_SDR_CURVE && (
1222
+ <span className="text-[10px] px-1 py-px rounded bg-white/15 text-white/70">default</span>
1223
+ )}
1224
+ </div>
1225
+ <p className="text-[11px] text-white/40">
1226
+ {canNavCurves ? 'Click the image or press ← → to compare · Esc or click outside to close' : 'Esc or click outside to close'}
1227
+ </p>
1228
+ </div>,
1229
+ document.body,
1230
+ )}
1231
+ </>)
1232
+ }
1233
+
320
1234
  if (status === 'done') {
321
1235
  // Prefer the promoted R2 media (poll path); fall back to a workspace path
322
1236
  // resolved via fileUrl (SSE path); else show a graceful no-player view.
@@ -369,9 +1283,20 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
369
1283
  download={downloadName}
370
1284
  className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-green-800/60 border border-green-700 text-green-200 hover:bg-green-700/60 transition-colors font-medium"
371
1285
  >
372
- Download
1286
+ {extraOutputs.length > 0 ? 'Download HDR master' : 'Download'}
373
1287
  </a>
374
1288
  )}
1289
+ {/* A `both` export's extra files (the derived SDR sibling). */}
1290
+ {extraOutputs.map((p) => (
1291
+ <a
1292
+ key={p}
1293
+ href={adapter.fileUrl(p)}
1294
+ download={basename(p)}
1295
+ className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-[var(--editor-surface)] border border-green-800 text-green-200/90 hover:bg-green-900/40 transition-colors font-medium"
1296
+ >
1297
+ Download SDR version
1298
+ </a>
1299
+ ))}
375
1300
  <button
376
1301
  onClick={onClose}
377
1302
  className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:opacity-90 transition-colors"
@@ -387,61 +1312,109 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
387
1312
  }
388
1313
 
389
1314
  return createPortal(
390
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
391
- <div className={`w-full ${view === 'logs' ? 'max-w-3xl' : 'max-w-md'} bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden`}>
1315
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4">
1316
+ <div className="w-full max-w-md bg-[var(--editor-surface)] border border-white/10 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
392
1317
 
393
1318
  {/* Header */}
394
- <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
1319
+ <div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
395
1320
  <div className="flex items-center gap-2.5">
396
- {status === 'running' && <span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />}
1321
+ {status === 'running' && <span className="w-2 h-2 rounded-full bg-[var(--editor-accent)] animate-pulse" />}
397
1322
  {status === 'error' && <span className="w-2 h-2 rounded-full bg-red-400" />}
398
- <div className="flex flex-col gap-0.5">
399
- <h2 className="text-sm font-semibold text-[var(--editor-text)]">
400
- {status === 'running' ? 'Rendering…' : 'Render failed'}
401
- </h2>
402
- </div>
1323
+ <h2 className="text-sm font-semibold text-[var(--editor-text)]">
1324
+ {status === 'running' ? 'Rendering…' : 'Render failed'}
1325
+ </h2>
403
1326
  </div>
404
1327
  {status !== 'running' && (
405
1328
  <button onClick={onClose} className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
406
1329
  )}
407
1330
  </div>
408
1331
 
409
- {/* Body — full log panel (montaj-native / SSE) or phase stepper (Hub) */}
410
- {view === 'logs' ? (
411
- <div className="relative">
412
- <button
413
- onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
414
- className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] hover:border-[var(--editor-border)] transition-colors"
415
- title="Copy logs"
416
- >
417
- Copy
418
- </button>
419
- <div
420
- ref={logRef}
421
- className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[var(--editor-text)]/80 bg-[var(--editor-surface)] flex flex-col gap-0.5"
422
- >
423
- {logs.length === 0 && status === 'running' && (
424
- <span className="text-[var(--editor-text)]/40 italic">Starting render engine…</span>
425
- )}
426
- {logs.map((line, i) => (
427
- <LogLine key={i} text={line} />
428
- ))}
429
- {status === 'error' && errorMsg && (
430
- <span className="text-red-400 mt-1">{errorMsg}</span>
431
- )}
1332
+ {status === 'running' ? (
1333
+ /* Stylized loading state: equalizer + phase + progress sweep. */
1334
+ <div className="flex flex-col items-center gap-6 px-6 py-9">
1335
+ <Loader size="lg" />
1336
+
1337
+ <div className="flex flex-col items-center gap-1 text-center">
1338
+ <h3 className="text-base font-semibold text-[var(--editor-text)]">Exporting your video</h3>
1339
+ <p className="text-xs text-[var(--editor-text)]/50">This can take a few minutes for longer videos.</p>
432
1340
  </div>
1341
+
1342
+ {/* Progress bar — always visible while rendering, above the stepper
1343
+ (poll hosts) or the render-log disclosure (SSE hosts). Poll hosts
1344
+ have a real `phase`; the SSE log transport pins phase to
1345
+ 'rendering', so there we infer progress from the log stream. */}
1346
+ <ProgressBar value={
1347
+ view === 'logs'
1348
+ ? parseLogProgress(logs)
1349
+ : (() => {
1350
+ const ph = stepperPhases(renderOpts)
1351
+ const i = ph.indexOf(phase)
1352
+ return i >= 0 ? i / ph.length : (phase === 'done' ? 1 : null)
1353
+ })()
1354
+ } />
1355
+
1356
+ {/* Granular phase progress (poll hosts advance through phases). */}
1357
+ {view !== 'logs' && (
1358
+ <div className="w-full max-w-xs rounded-xl border border-white/10 bg-[#0e131f] px-4 py-3">
1359
+ <PhaseStepper current={phase} phases={stepperPhases(renderOpts)} />
1360
+ </div>
1361
+ )}
1362
+
1363
+ {/* Raw render log, collapsed by default (montaj-native / SSE). */}
1364
+ {view === 'logs' && (
1365
+ <div className="w-full">
1366
+ <button
1367
+ onClick={() => setShowLog(o => !o)}
1368
+ aria-expanded={showLog}
1369
+ className="mx-auto flex items-center gap-1 text-[11px] text-[var(--editor-text)]/50 hover:text-[var(--editor-text)]/80 transition-colors"
1370
+ >
1371
+ {showLog ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
1372
+ {showLog ? 'Hide render log' : 'Show render log'}
1373
+ </button>
1374
+ {showLog && (
1375
+ <div className="relative mt-2">
1376
+ <button
1377
+ onClick={() => navigator.clipboard.writeText(logs.join('\n'))}
1378
+ className="absolute top-2 right-2 z-10 rounded border border-white/10 bg-[#0a0e17] px-2 py-0.5 text-[10px] text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] transition-colors"
1379
+ title="Copy logs"
1380
+ >
1381
+ Copy
1382
+ </button>
1383
+ <div
1384
+ ref={logRef}
1385
+ className="flex h-52 flex-col gap-0.5 overflow-y-auto rounded-lg border border-white/10 bg-[#0a0e17] px-3 py-2 font-mono text-[11px] text-[var(--editor-text)]/80"
1386
+ >
1387
+ {logs.length === 0
1388
+ ? <span className="italic text-[var(--editor-text)]/40">Starting render engine…</span>
1389
+ : logs.map((line, i) => <LogLine key={i} text={line} />)}
1390
+ </div>
1391
+ </div>
1392
+ )}
1393
+ </div>
1394
+ )}
433
1395
  </div>
434
1396
  ) : (
1397
+ /* Error state. */
435
1398
  <div className="px-5 py-5">
436
- {status === 'running' ? (
437
- <>
438
- <PhaseStepper current={phase} />
439
- <p className="mt-5 text-xs text-[var(--editor-text)]/50">
440
- This can take a few minutes for longer videos.
441
- </p>
442
- </>
1399
+ {view === 'logs' ? (
1400
+ <div className="relative">
1401
+ <button
1402
+ onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
1403
+ className="absolute top-2 right-2 z-10 rounded border border-white/10 bg-[#0a0e17] px-2 py-0.5 text-[10px] text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] transition-colors"
1404
+ title="Copy logs"
1405
+ >
1406
+ Copy
1407
+ </button>
1408
+ <div
1409
+ ref={logRef}
1410
+ className="flex h-72 flex-col gap-0.5 overflow-y-auto rounded-lg border border-white/10 bg-[#0a0e17] px-3 py-2 font-mono text-[11px] text-[var(--editor-text)]/80"
1411
+ >
1412
+ {logs.map((line, i) => <LogLine key={i} text={line} />)}
1413
+ {errorMsg && <span className="mt-1 text-red-400">{errorMsg}</span>}
1414
+ </div>
1415
+ </div>
443
1416
  ) : (
444
- <p className="text-sm text-red-400 whitespace-pre-wrap break-words">
1417
+ <p className={`text-sm whitespace-pre-wrap break-words ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>
445
1418
  {errorMsg ?? 'Render failed.'}
446
1419
  </p>
447
1420
  )}
@@ -449,18 +1422,18 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
449
1422
  )}
450
1423
 
451
1424
  {/* Footer */}
452
- <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
1425
+ <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-white/10">
453
1426
  {status === 'running' ? (
454
1427
  <button
455
1428
  onClick={handleCancel}
456
- className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 hover:bg-red-900/40 hover:border-red-700 hover:text-red-300 transition-colors"
1429
+ className={`text-sm px-4 py-1.5 rounded-md bg-[#0e131f] border border-white/10 text-[var(--editor-text)]/80 transition-colors ${mode === 'light' ? 'hover:bg-red-50 hover:border-red-300 hover:text-red-700' : 'hover:bg-red-900/40 hover:border-red-700 hover:text-red-300'}`}
457
1430
  >
458
1431
  Cancel
459
1432
  </button>
460
1433
  ) : (
461
1434
  <button
462
1435
  onClick={onClose}
463
- className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)] hover:opacity-90 transition-colors"
1436
+ className="text-sm px-4 py-1.5 rounded-md bg-[#0e131f] border border-white/10 text-[var(--editor-text)] hover:opacity-90 transition-colors"
464
1437
  >
465
1438
  Close
466
1439
  </button>