@bycrux/editor 0.12.0 → 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 (266) 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 +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  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 +586 -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 +6 -2
  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 +2202 -251
  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/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -1,24 +1,103 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react'
2
- import { Crop, Info, Magnet, Pencil, Redo2, Undo2 } from 'lucide-react'
1
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
2
+ import { Captions, Crop, Ear, EarOff, Film, HelpCircle, History, Magnet, Maximize2, Minimize2, Redo2, SeparatorVertical, Smartphone, SquareDashedMousePointer, Undo2, Wand2 } from 'lucide-react'
3
3
  import type { Project, VideoEditorProps } from '../types'
4
+ import type { AudioTrack, VisualItem } from '../schema'
4
5
  import { useProjectSync, type UseProjectSync } from '../state/use-project-sync'
5
6
  import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
6
7
  import ControlsInfoModal, { VIDEO_CONTROLS } from '../ControlsInfoModal'
8
+ import { Tooltip } from '../ui/Tooltip'
9
+ import { reviveNumberInRange, usePersistentState } from '../ui/usePersistentState'
7
10
  import { getOverlayDesignCanvas } from './design-canvas'
8
- import { applyTheme, defaultMontajTheme } from '../theme'
9
- import { applyCutToItem, applyCutToTracks, collapseGaps, splitAtTime } from './cuts'
11
+ import { availableResolutionTiers, availableFpsTiers, currentResolutionTier, maxExportFps } from './export-limits'
12
+ import { applyTheme, defaultMontajTheme, isLightTheme } from '../theme'
13
+ import { collapseGaps, rippleDelete, splitAtTime } from './cuts'
10
14
  import { repairCaptionWords } from './captionRepair'
11
- import Timeline from './timeline/Timeline'
15
+ import { maxCaptionLane, normalizeCaptionLanes } from './captionLanes'
16
+ import Timeline, { type TimelineActions, type TimelineMode } from './timeline/Timeline'
17
+ import { computeAutoCrossfade, computeDerivedTiming, enabledTrackItems, mapTrackItems, trackItems } from './timeline/timeline-model'
12
18
  import { makeCaptionEdit, type CaptionEditPatch } from './timeline/makeCaptionEdit'
13
- import PreviewPlayer from './preview/PreviewPlayer'
14
- import { createPlaybackClock, type PlaybackClock } from './playback-clock'
19
+ import PreviewPlayer, { type TransportHandle, type ScrubHandle } from './preview/PreviewPlayer'
20
+ import SocialPreviewMenu, { PlatformGlyph, platformOption } from './preview/SocialPreviewMenu'
21
+ import type { SocialPreviewPlatform } from './preview/SocialSafeZoneOverlay'
22
+ import { createPlaybackClock, usePlaybackTime, type PlaybackClock } from './playback-clock'
23
+ import { createHoverScrub } from './hover-scrub'
24
+ import { createScrubSource, type ScrubSource } from '../engine/scrub-source'
25
+ import { createScrubResolver } from '../engine/scrub-resolve'
26
+ import { useSourcePreview, type SourcePreviewStore } from './source-preview'
27
+ import { formatTimecode } from './timecode'
15
28
  import type { OverlayChanges } from './preview/useDragOverlay'
16
- import VersionPanel from './VersionPanel'
29
+ import VersionPanel, { listVersions } from './VersionPanel'
30
+ import OverlayInspector from './OverlayInspector'
31
+ import LeftPanelTabs, { type LeftPanelTab } from './panels/LeftPanelTabs'
32
+ import ClipPropertiesPanel, { type ClipSelection } from './panels/ClipPropertiesPanel'
33
+ import OverlayContentPanel from './panels/OverlayContentPanel'
34
+ import TabNav from './panels/TabNav'
35
+ import VersionCompare from './VersionCompare'
36
+ import CaptionListPanel, { type CaptionListPanelProps, type CaptionEditFocusRequest, nextEditFocus } from './CaptionListPanel'
17
37
  import RenderModal from './RenderModal'
18
38
  import ImageToneMenu from './ImageToneMenu'
19
39
  import type { ImageTone } from './imageTone'
20
40
  import CaptionRegenModal from './CaptionRegenModal'
21
- import OverlayPropsModal from './preview/OverlayPropsModal'
41
+ import AudioPolishModal from './AudioPolishModal'
42
+ import CommandPalette, { type PaletteCommand } from './CommandPalette'
43
+ import { createShuttleController } from './shuttle'
44
+ import { useKeymap, matchesKey, matchesModAltKey, matchesModKey, matchesPlainKey, matchesRedo, matchesShiftDelete, matchesUndo } from './keymap'
45
+ import { useReportContext } from './use-report-context'
46
+ import { copySelection, duplicateSelection, pasteAt, pasteAttributes, type ClipboardPayload } from './clipboard-ops'
47
+
48
+ // ── Layout preferences ───────────────────────────────────────────────────
49
+ // Persisted per browser (not per project): how tall the timeline pane is, and
50
+ // whether the caption row is shown. Keys are namespaced so they can't collide
51
+ // with a host's own localStorage.
52
+
53
+ const TIMELINE_PANE_STORAGE_KEY = 'montaj.editor.timelinePaneHeight'
54
+ /** Starting height of the timeline pane — roughly the base track, one overlay
55
+ * row, an audio lane and the caption row, which is what the fixed layout used
56
+ * to come out at. */
57
+ const DEFAULT_TIMELINE_PANE_PX = 300
58
+ /** Floor: the toolbar plus enough of the scrubber to still aim at. Below this
59
+ * the pane stops being a timeline. */
60
+ const MIN_TIMELINE_PANE_PX = 140
61
+ /** Ceiling, so a stored height from a much taller window can't open the editor
62
+ * with the preview pushed off-screen. */
63
+ const MAX_TIMELINE_PANE_PX = 1200
64
+ /** Always left to the preview, so the divider can never strand the picture at
65
+ * zero height. */
66
+ const MIN_PREVIEW_PANE_PX = 160
67
+
68
+ const RAIL_WIDTH_STORAGE_KEY = 'montaj.editor.railWidth'
69
+ /** Floor/ceiling for the right rail, and the width always left to the editor
70
+ * beside it — the vertical counterpart of the timeline pane's clamps. */
71
+ const MIN_RAIL_PX = 150
72
+ const MAX_RAIL_PX = 720
73
+ /** Default/reset rail width, wide enough for the sidebar CaptionListPanel's
74
+ * list and controls (previously 224 in 'sidebar' placement, 192 elsewhere —
75
+ * now one shared default across placements). */
76
+ const DEFAULT_RAIL_PX = 300
77
+ const MIN_MAIN_PX = 320
78
+
79
+ // ── CapCut media-panel width (opt-in via slots.mediaPanel) ─────────────────
80
+ // The left media column's width, mirroring the right rail on the same axis.
81
+ // Only consulted by the CapCut layout branch; classic layouts never read it.
82
+ const MEDIA_PANEL_WIDTH_STORAGE_KEY = 'montaj.editor.mediaPanelWidth'
83
+ /** Default matches today's `w-72` assets column so the switch feels familiar. */
84
+ const DEFAULT_MEDIA_PANEL_PX = 288
85
+ const MIN_MEDIA_PANEL_PX = 200
86
+ const MAX_MEDIA_PANEL_PX = 640
87
+
88
+ // ── Overlay properties panel tab ──────────────────────────────────────────
89
+ // Which of the right column's two overlay tabs is showing. Persisted per
90
+ // browser like every other panel preference, so the operator's habit survives
91
+ // a reload. 'content' is the default: what an overlay SAYS is what you reach
92
+ // for first, and it is the tab that replaced the double-click dialog.
93
+ const OVERLAY_PANEL_TAB_STORAGE_KEY = 'montaj.editor.overlayPanelTab'
94
+ type OverlayPanelTab = 'content' | 'transform'
95
+ const reviveOverlayPanelTab = (raw: unknown): OverlayPanelTab | null =>
96
+ raw === 'content' || raw === 'transform' ? raw : null
97
+ const OVERLAY_PANEL_TABS: readonly { value: OverlayPanelTab; label: string }[] = [
98
+ { value: 'content', label: 'Content' },
99
+ { value: 'transform', label: 'Transform' },
100
+ ]
22
101
 
23
102
  // Generic over the host's concrete project type `P` (default = the package's
24
103
  // own `Project`). Montaj passes its richer Project; the index signature on
@@ -93,12 +172,18 @@ export default function VideoEditor<P extends Project = Project>({
93
172
  onBackToSetup,
94
173
  assetsPlacement = 'right',
95
174
  renderProgressView = 'phases',
96
- renderClipInspector,
175
+ renderGenerationPanel,
97
176
  renderSubcutRegen,
98
177
  regenEnabled,
99
178
  isClipQueued,
100
179
  onProvideRenderTrigger,
101
180
  onProvideImageTone,
181
+ engine,
182
+ sourcePreview,
183
+ onRegenerateCaptions,
184
+ captionsGenerating,
185
+ onImportFilesToTimeline,
186
+ pendingDrops,
102
187
  }: Props<P>) {
103
188
  const emit = onProjectChange ?? (() => {})
104
189
 
@@ -109,6 +194,22 @@ export default function VideoEditor<P extends Project = Project>({
109
194
  // frames itself (video-shaped → default plain-replace reconcile).
110
195
  const sync = useProjectSync<P>(adapter, project.id, project)
111
196
 
197
+ // Set for the duration of a caption drag gesture (ReviewSurface's
198
+ // `handleProjectChange` → `commitTimelineEdit`), read by the lane-
199
+ // normalization effect just below. A cross-row drag deliberately leaves a
200
+ // HOLE lane open for the whole gesture (pointer-machine.ts normalizes only
201
+ // at commit, so the vacated lane's band stays visible as a drop target and
202
+ // the timeline doesn't jump under the pointer). Every `handleProjectChange`
203
+ // frame is a fresh `mutateTransient` call, which gives `captions` a new
204
+ // identity each mousemove — without this flag the effect below would see
205
+ // that hole on the very first move and call `sync.applyExternal`, which
206
+ // clears the sync core's transient baseline (`use-project-sync.ts`) and
207
+ // costs the whole gesture its single undo entry (`commitTimelineEdit`
208
+ // would then re-seed the baseline from the already-moved mid-drag state).
209
+ // A ref, not state: this must be readable synchronously inside the same
210
+ // handlers that flip it, with no re-render in between.
211
+ const captionGestureRef = useRef(false)
212
+
112
213
  // Every caption segment needs a stable `id` for selection (preview drag,
113
214
  // clickable timeline row). Segments saved before `id` existed on the schema
114
215
  // are missing it, and `steps/lyrics/caption.py` still writes segments without
@@ -133,13 +234,41 @@ export default function VideoEditor<P extends Project = Project>({
133
234
  // therefore re-fires this effect exactly once) finds nothing to do and stops.
134
235
  // Every other re-fire — one per caption edit — is a cheap `.every()` no-op.
135
236
  //
237
+ // The SECOND pass in the same effect is caption LANES. A project.json can
238
+ // arrive with sparse or hand-authored lanes (`lane: 7` on the only segment
239
+ // that has one, written by an agent or edited by hand), and every reader
240
+ // downstream — the bands the painter emits, the row the hit-test addresses,
241
+ // the fan-out a cross-row drag searches — assumes lanes are dense from 0. So
242
+ // normalize on load: `lane: 7` opens as row 1, not as eight rows of mostly
243
+ // nothing. `normalizeCaptionLanes` honours the same same-reference contract
244
+ // as `backfillCaptionIds`, so it is loop-proof for the same reason.
245
+ //
246
+ // Both passes share ONE effect, and the lane pass reads the BACKFILLED
247
+ // project rather than `sync.project`, on purpose: two effects with the same
248
+ // deps both close over the same pre-effect `sync.project`, so the second
249
+ // `applyExternal` of a commit lands a project derived from the state as it
250
+ // was BEFORE the first one and drops the ids the backfill just minted. It
251
+ // recovers on the next pass (the effect re-fires and re-mints), but only
252
+ // after publishing a half-normalized project to the host through
253
+ // `onProjectChange` and paying an extra render for it. Chained, the host only
254
+ // ever sees the input or the finished result.
255
+ //
136
256
  // `applyExternal` — no save, no undo push: this is normalization of loaded
137
257
  // data, not a user edit, so it must not dirty the project or contend with the
138
- // undo stack; the ids persist naturally the next time the operator makes a
139
- // real edit.
258
+ // undo stack; the ids and lanes persist naturally the next time the operator
259
+ // makes a real edit.
140
260
  useEffect(() => {
141
261
  const backfilled = backfillCaptionIds(sync.project)
142
- if (backfilled !== sync.project) sync.applyExternal(backfilled)
262
+ // Lane normalization is for captions ARRIVING from outside (mount, SSE, regen,
263
+ // a hand-authored project.json) -- never for a mid-gesture transient frame. A
264
+ // cross-row drag deliberately leaves a HOLE lane open for the whole gesture
265
+ // (pointer-machine normalizes at commit), and `applyExternal` clears the sync
266
+ // core's transient baseline, which would cost the gesture its single undo entry.
267
+ const captions = captionGestureRef.current
268
+ ? backfilled.captions
269
+ : normalizeCaptionLanes(backfilled.captions)
270
+ const normalized = captions === backfilled.captions ? backfilled : { ...backfilled, captions }
271
+ if (normalized !== sync.project) sync.applyExternal(normalized)
143
272
  }, [sync.project.id, sync.project.captions])
144
273
 
145
274
  // Notify the host of every authoritative change — edits, undo/redo, and SSE
@@ -158,11 +287,27 @@ export default function VideoEditor<P extends Project = Project>({
158
287
  if (containerRef.current) applyTheme(containerRef.current, theme ?? defaultMontajTheme)
159
288
  }, [theme])
160
289
 
290
+ // The chrome flips via the `--editor-*` vars `applyTheme` just wrote; the
291
+ // canvas timeline cannot read a CSS variable, so it is handed the answer
292
+ // directly. Classified ONCE here, off the same theme object, rather than
293
+ // per-surface — three consumers (canvas, track rail, fade-shape icons)
294
+ // resolving it independently is three chances to disagree. Memoized on
295
+ // `theme` so the prop identity is stable across the many re-renders an edit
296
+ // causes, and `TimelineCanvas`'s repaint-on-mode-change effect fires only
297
+ // when the mode has actually moved.
298
+ const timelineMode = useMemo<TimelineMode>(
299
+ () => (isLightTheme(theme ?? defaultMontajTheme) ? 'light' : 'dark'),
300
+ [theme],
301
+ )
302
+
161
303
  const isPending = sync.project.status === 'pending'
162
304
 
163
305
  // ── Shared injected adapter fns, threaded to Timeline + PreviewPlayer. ──
164
- const getWaveformChunks = adapter.getWaveformChunks
165
306
  const resolveFilePath = adapter.fileUrl
307
+ // T6 — canvas-timeline waveforms; absent → Timeline renders none (graceful).
308
+ const getWaveformPeaks = adapter.getWaveformPeaks
309
+ // T7 — canvas-timeline filmstrips + hover-scrub; absent → Timeline renders none (graceful).
310
+ const getFilmstrip = adapter.getFilmstrip
166
311
 
167
312
  if (isPending) {
168
313
  return (
@@ -172,30 +317,42 @@ export default function VideoEditor<P extends Project = Project>({
172
317
  adapter={adapter}
173
318
  slots={slots}
174
319
  onBackToSetup={onBackToSetup}
175
- getWaveformChunks={getWaveformChunks}
176
320
  resolveFilePath={resolveFilePath}
321
+ getWaveformPeaks={getWaveformPeaks}
322
+ getFilmstrip={getFilmstrip}
323
+ engine={engine}
324
+ timelineMode={timelineMode}
177
325
  />
178
326
  </div>
179
327
  )
180
328
  }
181
329
 
182
330
  return (
183
- <div ref={containerRef} className="flex flex-col h-full">
331
+ <div ref={containerRef} className="flex flex-col h-full bg-[var(--editor-bg)]">
184
332
  <ReviewSurface
185
333
  sync={sync}
334
+ captionGestureRef={captionGestureRef}
186
335
  emit={emit}
187
336
  adapter={adapter}
188
337
  slots={slots}
189
338
  assetsPlacement={assetsPlacement}
190
339
  renderProgressView={renderProgressView}
191
- getWaveformChunks={getWaveformChunks}
192
340
  resolveFilePath={resolveFilePath}
193
- renderClipInspector={renderClipInspector}
341
+ getWaveformPeaks={getWaveformPeaks}
342
+ getFilmstrip={getFilmstrip}
343
+ renderGenerationPanel={renderGenerationPanel}
194
344
  renderSubcutRegen={renderSubcutRegen}
195
345
  regenEnabled={regenEnabled}
196
346
  isClipQueued={isClipQueued}
197
347
  onProvideRenderTrigger={onProvideRenderTrigger}
198
348
  onProvideImageTone={onProvideImageTone}
349
+ engine={engine}
350
+ sourcePreview={sourcePreview}
351
+ onRegenerateCaptions={onRegenerateCaptions}
352
+ captionsGenerating={captionsGenerating}
353
+ onImportFilesToTimeline={onImportFilesToTimeline}
354
+ pendingDrops={pendingDrops}
355
+ timelineMode={timelineMode}
199
356
  />
200
357
  </div>
201
358
  )
@@ -206,12 +363,92 @@ export default function VideoEditor<P extends Project = Project>({
206
363
  function useVersionHistory<P extends Project>(adapter: VideoEditorProps<P>['adapter'], project: P) {
207
364
  const [versions, setVersions] = useState<{ hash: string; message: string; timestamp: string }[]>([])
208
365
  const [restoring, setRestoring] = useState<string | null>(null)
366
+ const [saving, setSaving] = useState(false)
367
+
368
+ // Extracted so ad-hoc callers (post-restore, post-save, post-render) can
369
+ // re-run the same fetch on demand via `refresh`, without duplicating the
370
+ // adapter call or bypassing the auto-refetch effect below.
371
+ const fetchVersions = useCallback(() => {
372
+ return adapter.listVersionHistory?.(project.id).then(setVersions).catch(() => {}) ?? Promise.resolve()
373
+ }, [adapter, project.id])
209
374
 
210
375
  useEffect(() => {
211
- adapter.listVersionHistory?.(project.id).then(setVersions).catch(() => {})
212
- }, [adapter, project.id, project.status])
376
+ void fetchVersions()
377
+ }, [fetchVersions, project.status])
213
378
 
214
- return { versions, restoring, setRestoring }
379
+ return { versions, restoring, setRestoring, saving, setSaving, refresh: fetchVersions }
380
+ }
381
+
382
+ // ── Footage-bin source-scrub overlay (opt-in) ────────────────────────────────
383
+
384
+ /**
385
+ * A paused `<video>` overlaid on the main preview stage, driven by the host's
386
+ * footage bin: while a bin clip card is hovered the host writes `{ url, fraction }`
387
+ * to the `sourcePreview` store and this parks the video on `fraction × duration`,
388
+ * so the operator source-scrubs an OFF-TIMELINE clip in the big preview without
389
+ * disturbing the playhead or PreviewPlayer's own clock.
390
+ *
391
+ * Structurally a sibling of `hover-scrub`: the subscription lives HERE, in a leaf
392
+ * that renders one element, so a high-frequency hover over a card repaints only
393
+ * this overlay — never ReviewSurface (toolbar + timeline + every context
394
+ * consumer). The seek is imperative (an effect subscribed to the store writes
395
+ * `currentTime` directly) rather than React state, so a fraction move never
396
+ * re-mounts or reloads the `<video>`; only a url change (a different clip) does.
397
+ *
398
+ * Entirely inert when the host omits the store: `useSourcePreview(undefined)`
399
+ * returns null, so this renders nothing and the classic/Hub/LP preview is
400
+ * unchanged.
401
+ */
402
+ function SourcePreviewOverlay({ store }: { store?: SourcePreviewStore }) {
403
+ const value = useSourcePreview(store)
404
+ const videoRef = useRef<HTMLVideoElement | null>(null)
405
+
406
+ // Imperative seek. Re-subscribes only when the url changes (a different clip);
407
+ // fraction moves flow through the store subscription without a dep change, and
408
+ // the `<video>` element/src stay put. A fresh src resets metadata, so seeking
409
+ // is deferred to `loadedmetadata` when `duration` isn't finite yet — that
410
+ // handler reads the CURRENT fraction, so a hover that moved while the proxy
411
+ // loaded still lands on the right frame.
412
+ useEffect(() => {
413
+ const v = videoRef.current
414
+ if (!v || !store) return
415
+ const applySeek = () => {
416
+ const cur = store.get()
417
+ if (!cur) return
418
+ const dur = v.duration
419
+ if (Number.isFinite(dur) && dur > 0) v.currentTime = cur.fraction * dur
420
+ }
421
+ v.addEventListener('loadedmetadata', applySeek)
422
+ const unsub = store.subscribe(applySeek)
423
+ applySeek()
424
+ return () => {
425
+ v.removeEventListener('loadedmetadata', applySeek)
426
+ unsub()
427
+ }
428
+ }, [store, value?.url])
429
+
430
+ if (!value) return null
431
+ return (
432
+ <video
433
+ ref={videoRef}
434
+ src={value.url}
435
+ muted
436
+ playsInline
437
+ preload="metadata"
438
+ // Display-only frame scrubber: never `.play()`, never steal pointer
439
+ // interaction from the preview beneath it.
440
+ style={{
441
+ position: 'absolute',
442
+ inset: 0,
443
+ width: '100%',
444
+ height: '100%',
445
+ objectFit: 'contain',
446
+ background: 'black',
447
+ zIndex: 20,
448
+ pointerEvents: 'none',
449
+ }}
450
+ />
451
+ )
215
452
  }
216
453
 
217
454
  // ── Pending / processing surface (former LiveView) ───────────────────────────
@@ -222,10 +459,16 @@ interface SurfaceProps<P extends Project> {
222
459
  slots?: VideoEditorProps<P>['slots']
223
460
  assetsPlacement?: VideoEditorProps<P>['assetsPlacement']
224
461
  renderProgressView?: VideoEditorProps<P>['renderProgressView']
225
- getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
226
462
  resolveFilePath: (path: string) => string
463
+ getWaveformPeaks?: VideoEditorProps<P>['adapter']['getWaveformPeaks']
464
+ getFilmstrip?: VideoEditorProps<P>['adapter']['getFilmstrip']
227
465
  onProvideRenderTrigger?: VideoEditorProps<P>['onProvideRenderTrigger']
228
466
  onProvideImageTone?: VideoEditorProps<P>['onProvideImageTone']
467
+ engine?: VideoEditorProps<P>['engine']
468
+ /** Light/dark for the canvas timeline, resolved from the host theme by
469
+ * `VideoEditor` (the canvas can't read the CSS vars the rest of the chrome
470
+ * uses). Both surfaces render a `Timeline`, so both need it. */
471
+ timelineMode: TimelineMode
229
472
  }
230
473
 
231
474
  function PendingSurface<P extends Project>({
@@ -233,8 +476,11 @@ function PendingSurface<P extends Project>({
233
476
  adapter,
234
477
  slots,
235
478
  onBackToSetup,
236
- getWaveformChunks,
237
479
  resolveFilePath,
480
+ getWaveformPeaks,
481
+ getFilmstrip,
482
+ engine,
483
+ timelineMode,
238
484
  }: SurfaceProps<P> & { onBackToSetup?: () => void }) {
239
485
  const project = sync.project
240
486
  // The playhead lives in an external store (not useState) so ~60Hz ticks only
@@ -244,13 +490,17 @@ function PendingSurface<P extends Project>({
244
490
  const clock = clockRef.current
245
491
  const [skillPath, setSkillPath] = useState<string | null>(null)
246
492
  const [copied, setCopied] = useState(false)
247
- const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
493
+ const { versions, restoring, setRestoring, saving, setSaving, refresh: refreshVersions } = useVersionHistory(adapter, project)
494
+ // The version-compare view: the LEFT hash it was opened for, or null when
495
+ // closed. Lives beside `versions` since VersionCompare's picker is seeded
496
+ // from the same list VersionPanel renders.
497
+ const [compareOpen, setCompareOpen] = useState<string | null>(null)
248
498
 
249
499
  useEffect(() => {
250
500
  adapter.getInfo?.().then(info => setSkillPath(info.root_skill_path ?? null)).catch(() => {})
251
501
  }, [adapter])
252
502
 
253
- const clips = project.tracks?.[0] ?? []
503
+ const clips = trackItems(project)[0] ?? []
254
504
  const hasTrimmedClips = clips.some(c => c.inPoint !== undefined && c.outPoint !== undefined)
255
505
  // The back-to-setup affordance is gated on the host supplying it AND the
256
506
  // project being safe to discard (no manual trims yet). Mirrors LiveView's
@@ -268,9 +518,27 @@ function PendingSurface<P extends Project>({
268
518
  console.error(e)
269
519
  } finally {
270
520
  setRestoring(null)
521
+ await refreshVersions()
271
522
  }
272
523
  }
273
524
 
525
+ async function handleSaveVersion(name?: string) {
526
+ if (!adapter.saveVersion) return
527
+ setSaving(true)
528
+ try {
529
+ await adapter.saveVersion(project.id, name)
530
+ await refreshVersions()
531
+ } catch (e) {
532
+ console.error(e)
533
+ } finally {
534
+ setSaving(false)
535
+ }
536
+ }
537
+
538
+ function handleCompareVersion(hash: string) {
539
+ setCompareOpen(hash)
540
+ }
541
+
274
542
  return (
275
543
  <div className="flex flex-1 overflow-hidden">
276
544
  {/* Main */}
@@ -285,6 +553,7 @@ function PendingSurface<P extends Project>({
285
553
  watchFile={adapter.watchFile}
286
554
  fileUrl={adapter.fileUrl}
287
555
  resolveCaptionTemplate={adapter.resolveCaptionTemplate}
556
+ engine={engine}
288
557
  />
289
558
  ) : (
290
559
  <div className="flex flex-col items-center gap-6 text-center max-w-lg w-full">
@@ -298,8 +567,28 @@ function PendingSurface<P extends Project>({
298
567
  </div>
299
568
  {skillPath && (
300
569
  <div className="w-full rounded-xl border-2 border-[var(--editor-accent)]/50 bg-[var(--editor-surface)] p-5 flex flex-col gap-3 text-left shadow-lg shadow-[var(--editor-accent)]/10">
301
- <p className="text-[var(--editor-accent)] text-xs font-bold uppercase tracking-widest">Send this to your agent</p>
302
- <div className="flex items-start justify-between bg-black/60 border border-transparent rounded-lg px-3 py-3 font-mono gap-3">
570
+ {/* indigo-600 in light mode: the accent (indigo-500) is ~4.06:1 on
571
+ `--editor-surface`, and 12px bold is still NORMAL text under WCAG
572
+ (large starts at 18.66px bold), so it needs the 4.5:1 floor. The
573
+ border/shadow around it stay accent - this is small-TEXT only. */}
574
+ <p className={`text-xs font-bold uppercase tracking-widest ${timelineMode === 'light' ? 'text-indigo-600' : 'text-[var(--editor-accent)]'}`}>Send this to your agent</p>
575
+ {/* Deliberately hardcoded dark chrome, not `--editor-*` tokens: this
576
+ is the literal text the user copies and pastes to their coding
577
+ agent, styled as terminal/code chrome — same precedent as the
578
+ video preview's black canvas, which also stays dark regardless
579
+ of editor theme.
580
+
581
+ This was `bg-black/60`, which composited against whatever sat
582
+ behind it: near-black over the dark surface, but only ~#666
583
+ over a light one — ~3:1 for 12px copyable text, below AA.
584
+ It is now an OPAQUE colour so the box no longer depends on
585
+ its backdrop. The specific value is not arbitrary: #070a10 is
586
+ exactly what `rgba(0,0,0,0.6)` over the dark theme's
587
+ `--editor-surface` (#111827) composited to, so dark mode is
588
+ pixel-identical to before while light mode is fixed. Text is
589
+ pinned to #e5e7eb (gray-200, matching this file's original),
590
+ so it is likewise unchanged in dark mode and ~16:1 in both. */}
591
+ <div className="flex items-start justify-between bg-[#070a10] border border-transparent rounded-lg px-3 py-3 font-mono gap-3">
303
592
  <span className="text-gray-200 text-[12px] leading-relaxed break-all">
304
593
  There is a new project pending: &quot;{project.name ?? project.id}&quot;. Please see @{skillPath} and start. Talk to me if you run into questions.
305
594
  </span>
@@ -312,7 +601,7 @@ function PendingSurface<P extends Project>({
312
601
  setTimeout(() => setCopied(false), 2000)
313
602
  }}
314
603
  className={`shrink-0 flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md transition-colors ${
315
- copied ? 'bg-green-700 text-green-200' : 'bg-[var(--editor-text)]/10 text-[var(--editor-text)]/80 hover:bg-[var(--editor-text)]/20 hover:text-[var(--editor-text)]'
604
+ copied ? 'bg-green-700 text-green-200' : 'bg-white/10 text-[#f3f4f6]/80 hover:bg-white/20 hover:text-gray-100'
316
605
  }`}
317
606
  title="Copy prompt"
318
607
  >
@@ -323,11 +612,11 @@ function PendingSurface<P extends Project>({
323
612
  )}
324
613
  </>
325
614
  )}
326
- <p className="text-[var(--editor-text)]/40 text-xs font-mono">project id: {project.id}</p>
615
+ <p className="text-gray-100/40 text-xs font-mono">project id: {project.id}</p>
327
616
  {canGoBack && (
328
617
  <button
329
618
  onClick={onBackToSetup}
330
- className="text-xs text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] transition-colors underline underline-offset-2"
619
+ className="text-xs text-gray-100/60 hover:text-gray-100 transition-colors underline underline-offset-2"
331
620
  >
332
621
  ← Back to setup
333
622
  </button>
@@ -340,9 +629,10 @@ function PendingSurface<P extends Project>({
340
629
  <Timeline
341
630
  project={project}
342
631
  clock={clock}
343
- getWaveformChunks={getWaveformChunks}
344
632
  resolveFilePath={resolveFilePath}
345
- onSaveProject={(p) => sync.mutate(() => p as P)}
633
+ getWaveformPeaks={getWaveformPeaks}
634
+ getFilmstrip={getFilmstrip}
635
+ mode={timelineMode}
346
636
  />
347
637
  </div>
348
638
  </div>
@@ -350,36 +640,84 @@ function PendingSurface<P extends Project>({
350
640
  {/* Right sidebar — version history (hidden when the capability is absent) */}
351
641
  {adapter.listVersionHistory && (
352
642
  <div className="w-48 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
353
- <VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
643
+ <VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} onSaveVersion={handleSaveVersion} saving={saving} onCompareVersion={adapter.versionFrameUrl ? handleCompareVersion : undefined} mode={timelineMode} />
354
644
  </div>
355
645
  )}
646
+
647
+ {/* Visual A/B version compare — opened from a VersionPanel entry's
648
+ Compare button. Gated on the adapter capability so a host without
649
+ `versionFrameUrl` never sees an unusable Compare affordance's modal. */}
650
+ {compareOpen != null && adapter.versionFrameUrl && (
651
+ <VersionCompare
652
+ projectId={project.id}
653
+ versions={listVersions(versions)}
654
+ initialLeftHash={compareOpen}
655
+ frameUrl={adapter.versionFrameUrl}
656
+ onClose={() => setCompareOpen(null)}
657
+ mode={timelineMode}
658
+ />
659
+ )}
356
660
  </div>
357
661
  )
358
662
  }
359
663
 
360
664
  // ── Draft / final surface (former ReviewView) ────────────────────────────────
361
665
 
666
+ // Every visual item id (across all tracks) plus every audio track id in a
667
+ // project. Used by the clipboard paste/duplicate handlers below to diff
668
+ // before/after a `pasteAt`/`duplicateSelection` call and find the ids those
669
+ // pure ops minted (`newClipId()`), so the freshly pasted/duplicated items can
670
+ // be selected — neither op returns the new ids directly.
671
+ function collectAllIds(project: Project): Set<string> {
672
+ const ids = new Set<string>()
673
+ for (const item of trackItems(project).flat()) ids.add(item.id)
674
+ for (const track of project.audio?.tracks ?? []) ids.add(track.id)
675
+ return ids
676
+ }
677
+
362
678
  function ReviewSurface<P extends Project>({
363
679
  sync,
680
+ captionGestureRef,
364
681
  emit,
365
682
  adapter,
366
683
  slots,
367
684
  assetsPlacement = 'right',
368
685
  renderProgressView = 'phases',
369
- getWaveformChunks,
370
686
  resolveFilePath,
371
- renderClipInspector,
687
+ getWaveformPeaks,
688
+ getFilmstrip,
689
+ renderGenerationPanel,
372
690
  renderSubcutRegen,
373
691
  regenEnabled,
374
692
  isClipQueued,
375
693
  onProvideRenderTrigger,
376
694
  onProvideImageTone,
695
+ engine,
696
+ sourcePreview,
697
+ onRegenerateCaptions,
698
+ captionsGenerating,
699
+ onImportFilesToTimeline,
700
+ pendingDrops,
701
+ timelineMode,
377
702
  }: SurfaceProps<P> & {
703
+ // See the definition beside `sync` in VideoEditor above — set for the
704
+ // duration of a caption drag gesture so the lane-normalization effect
705
+ // (also up in VideoEditor) leaves a mid-drag hole lane alone.
706
+ captionGestureRef: MutableRefObject<boolean>
378
707
  emit: (p: P) => void
379
- renderClipInspector?: VideoEditorProps<P>['renderClipInspector']
708
+ renderGenerationPanel?: VideoEditorProps<P>['renderGenerationPanel']
380
709
  renderSubcutRegen?: VideoEditorProps<P>['renderSubcutRegen']
381
710
  regenEnabled?: boolean
382
711
  isClipQueued?: (itemId: string) => boolean
712
+ sourcePreview?: VideoEditorProps<P>['sourcePreview']
713
+ onRegenerateCaptions?: VideoEditorProps<P>['onRegenerateCaptions']
714
+ captionsGenerating?: VideoEditorProps<P>['captionsGenerating']
715
+ // The filesystem-drop seam. On the REVIEW surface only: PendingSurface's
716
+ // timeline is a read-only preview of a project the agent is still building
717
+ // (it passes no `onProjectChange`/`onOverlayEdit` either), so there is
718
+ // nothing there for a dropped file to become.
719
+ onImportFilesToTimeline?: VideoEditorProps<P>['onImportFilesToTimeline']
720
+ pendingDrops?: VideoEditorProps<P>['pendingDrops']
383
721
  }) {
384
722
  const project = sync.project
385
723
  // Playhead in an external store, not useState — ~60Hz ticks re-render only the
@@ -389,25 +727,330 @@ function ReviewSurface<P extends Project>({
389
727
  if (!clockRef.current) clockRef.current = createPlaybackClock()
390
728
  const clock = clockRef.current
391
729
  // Multi-select: all currently-selected timeline item ids. Single-select
392
- // consumers (canvas preview, cut/split) use selectedIds[0] as the primary.
730
+ // consumers (canvas preview, cut/split) use primarySelectedId, derived below
731
+ // once captionIdSet exists — it has to skip caption ids, so it can't just be
732
+ // selectedIds[0].
393
733
  const [selectedIds, setSelectedIds] = useState<string[]>([])
394
- const primarySelectedId = selectedIds[0] ?? null
395
- // Selected caption segment id. Deliberately owned here rather than inside the
396
- // preview: it is shared selection state. The preview draws the selection box /
397
- // drag handles for it, and the timeline's caption row (later task) highlights
398
- // and seeks to the same segment — that sibling only needs `selectedCaptionId`
399
- // and `setSelectedCaptionId` passed down, no lifting required.
400
- const [selectedCaptionId, setSelectedCaptionId] = useState<string | null>(null)
734
+ // Canvas-timeline double-click → sidebar focus request (Phase 6). `nonce`
735
+ // is load-bearing: CaptionListPanel only re-focuses a row when `nonce`
736
+ // CHANGES (see its `lastHandledNonceRef` guard), so double-clicking the
737
+ // SAME caption twice must still produce two distinct requests — the bare
738
+ // id would be identical both times and the second double-click would be a
739
+ // silent no-op. Incrementing off the previous state (not a plain counter
740
+ // ref) keeps this correct even if two edits interleave with other renders.
741
+ const [editFocusId, setEditFocusId] = useState<CaptionEditFocusRequest | null>(null)
742
+ // Ids present in project.captions.segments — memoized so DERIVING
743
+ // selectedCaptionId below doesn't rescan every segment on every render, only
744
+ // when captions actually change.
745
+ const captionIdSet = useMemo(
746
+ () => new Set((project.captions?.segments ?? []).map(s => s.id).filter((id): id is string => !!id)),
747
+ [project.captions?.segments],
748
+ )
749
+ // The first NON-caption id in the selection, deliberately. Under D1 captions
750
+ // share `selectedIds` with clips and audio (see Timeline.tsx's
751
+ // `handleSelectItem`), but every consumer of this value — cropTarget,
752
+ // selectedOverlayItem, handleSplit's scope, PreviewPlayer's overlay
753
+ // selection box — speaks clip/audio vocabulary. Taking selectedIds[0]
754
+ // verbatim let a caption id at index 0 blank the selected clip's crop and
755
+ // overlay-edit affordances, and scoped Split to an id no track item
756
+ // matches, so `splitAtTime` returned its input unchanged and `S` silently
757
+ // did nothing. Captions are addressed separately, via `selectedCaptionId`
758
+ // below.
759
+ const primarySelectedId = selectedIds.find(id => !captionIdSet.has(id)) ?? null
760
+ // Selected caption segment id — DERIVED from `selectedIds`, not tracked as
761
+ // its own state. Under D1 a caption id is just another member of
762
+ // `selectedIds` (selected on the canvas timeline exactly like a clip or
763
+ // audio track — see Timeline.tsx's `handleSelectItem`); this is
764
+ // VideoEditor's mirror of "the first caption id in there, if any" for
765
+ // PreviewPlayer's selection box, the one remaining consumer that predates
766
+ // D1 and only understands a single id (CaptionListPanel, its other former
767
+ // sibling, derives its own selected segment straight from `selectedIds`
768
+ // instead of taking this prop). A marquee can legitimately put more than
769
+ // one caption id in `selectedIds` — taking the first is correct, the
770
+ // preview only ever shows one box.
771
+ const selectedCaptionId = selectedIds.find(id => captionIdSet.has(id)) ?? null
772
+ // Publish what we are looking at, so an agent can resolve "this section".
773
+ // No-ops entirely on a host that does not implement reportContext.
774
+ useReportContext({
775
+ adapter,
776
+ projectId: project.id,
777
+ clock,
778
+ selectedIds,
779
+ selectedCaptionId,
780
+ })
781
+ // Selecting a caption anywhere (timeline, preview box, or the caption list —
782
+ // all funnel through `selectedIds` → `selectedCaptionId`) jumps the left
783
+ // panel to its Captions tab. A per-selection nonce, not the id itself, so
784
+ // re-selecting the same caption after the user switched tabs still snaps
785
+ // back; only a truthy selection bumps it, so deselecting never yanks the tab.
786
+ const [captionTabNonce, setCaptionTabNonce] = useState(0)
787
+ useEffect(() => {
788
+ if (selectedCaptionId) setCaptionTabNonce(n => n + 1)
789
+ }, [selectedCaptionId])
401
790
  const [rippleMode, setRippleMode] = useState(false)
791
+ // CapCut's "preview axis", off by default. Off changes nothing: clicking the
792
+ // timeline moves the red playhead and the preview follows it, as always. On,
793
+ // a yellow cursor line tracks the pointer across the timeline and the preview
794
+ // shows THAT frame while the playhead stays put — hover to look around, click
795
+ // to actually go there.
796
+ const [previewAxis, setPreviewAxis] = useState(false)
797
+
798
+ // Copy/paste/duplicate clipboard (T2). A ref, not state: nothing in this
799
+ // surface needs to re-render when it changes — the keymap guards below read
800
+ // it synchronously at keydown time, and `paletteCommands` (built fresh every
801
+ // render) reads it whenever the palette's own `setPaletteOpen` call triggers
802
+ // that render.
803
+ const clipboardRef = useRef<ClipboardPayload | null>(null)
804
+
805
+ // ── Preview / timeline split ───────────────────────────────────────────
806
+ // The timeline pane owns an explicit height and the preview takes the rest,
807
+ // so one number describes the whole split. Persisted per browser, not per
808
+ // project: it's a property of the screen you're working on.
809
+ const splitRef = useRef<HTMLDivElement | null>(null)
810
+ const [timelinePaneHeight, setTimelinePaneHeight] = usePersistentState(
811
+ TIMELINE_PANE_STORAGE_KEY,
812
+ DEFAULT_TIMELINE_PANE_PX,
813
+ reviveNumberInRange(MIN_TIMELINE_PANE_PX, MAX_TIMELINE_PANE_PX),
814
+ )
815
+ // The right rail's width, same deal on the other axis. 300px (up from
816
+ // 224/192) is the new default so the sidebar CaptionListPanel's list and
817
+ // its "Caption style" controls aren't cramped — persisted per browser, so
818
+ // only a fresh/cleared localStorage picks up the new default.
819
+ const workAreaRef = useRef<HTMLDivElement | null>(null)
820
+ const [railWidth, setRailWidth] = usePersistentState(
821
+ RAIL_WIDTH_STORAGE_KEY,
822
+ DEFAULT_RAIL_PX,
823
+ reviveNumberInRange(MIN_RAIL_PX, MAX_RAIL_PX),
824
+ )
825
+ // The left media column's width (CapCut layout only). Same persist/clamp
826
+ // pattern as the rail; ignored entirely unless `slots.mediaPanel` is present.
827
+ const [mediaPanelWidth, setMediaPanelWidth] = usePersistentState(
828
+ MEDIA_PANEL_WIDTH_STORAGE_KEY,
829
+ DEFAULT_MEDIA_PANEL_PX,
830
+ reviveNumberInRange(MIN_MEDIA_PANEL_PX, MAX_MEDIA_PANEL_PX),
831
+ )
832
+ // Which overlay tab the right column opens on. Same persist-per-browser
833
+ // pattern as the widths above; the reviver rejects anything that isn't one
834
+ // of the two tab names, so a stale key from an older build falls back to
835
+ // 'content' rather than rendering a blank pane.
836
+ const [overlayPanelTab, setOverlayPanelTab] = usePersistentState<OverlayPanelTab>(
837
+ OVERLAY_PANEL_TAB_STORAGE_KEY,
838
+ 'content',
839
+ reviveOverlayPanelTab,
840
+ )
841
+
842
+ /** Drag the rail divider. Mirrors `startSplitDrag` on the horizontal axis;
843
+ * dragging LEFT widens the rail, hence the inverted delta. */
844
+ const startRailDrag = useCallback((e: React.MouseEvent) => {
845
+ e.preventDefault()
846
+ const startX = e.clientX
847
+ const startWidth = railWidth
848
+ const available = workAreaRef.current?.getBoundingClientRect().width ?? 0
849
+ const max = Math.max(MIN_RAIL_PX, Math.min(MAX_RAIL_PX, available - MIN_MAIN_PX))
850
+
851
+ let latest = startWidth
852
+ const onMove = (ev: MouseEvent) => {
853
+ latest = Math.max(MIN_RAIL_PX, Math.min(max, startWidth - (ev.clientX - startX)))
854
+ setRailWidth(latest, { persist: false })
855
+ }
856
+ const onUp = () => {
857
+ document.removeEventListener('mousemove', onMove)
858
+ document.removeEventListener('mouseup', onUp)
859
+ document.body.style.cursor = ''
860
+ document.body.style.userSelect = ''
861
+ setRailWidth(latest)
862
+ }
863
+ document.addEventListener('mousemove', onMove)
864
+ document.addEventListener('mouseup', onUp)
865
+ document.body.style.cursor = 'col-resize'
866
+ document.body.style.userSelect = 'none'
867
+ }, [railWidth, setRailWidth])
868
+
869
+ /** Drag the media-column divider (CapCut layout). The divider sits on the
870
+ * column's RIGHT edge, so dragging RIGHT must WIDEN it — hence the delta is
871
+ * ADDED (the mirror image of `startRailDrag`, whose rail grows leftward).
872
+ * `MIN_MAIN_PX` still guards the preview column's minimum width. */
873
+ const startMediaPanelDrag = useCallback((e: React.MouseEvent) => {
874
+ e.preventDefault()
875
+ const startX = e.clientX
876
+ const startWidth = mediaPanelWidth
877
+ const available = workAreaRef.current?.getBoundingClientRect().width ?? 0
878
+ const max = Math.max(MIN_MEDIA_PANEL_PX, Math.min(MAX_MEDIA_PANEL_PX, available - MIN_MAIN_PX))
879
+
880
+ let latest = startWidth
881
+ const onMove = (ev: MouseEvent) => {
882
+ latest = Math.max(MIN_MEDIA_PANEL_PX, Math.min(max, startWidth + (ev.clientX - startX)))
883
+ setMediaPanelWidth(latest, { persist: false })
884
+ }
885
+ const onUp = () => {
886
+ document.removeEventListener('mousemove', onMove)
887
+ document.removeEventListener('mouseup', onUp)
888
+ document.body.style.cursor = ''
889
+ document.body.style.userSelect = ''
890
+ setMediaPanelWidth(latest)
891
+ }
892
+ document.addEventListener('mousemove', onMove)
893
+ document.addEventListener('mouseup', onUp)
894
+ document.body.style.cursor = 'col-resize'
895
+ document.body.style.userSelect = 'none'
896
+ }, [mediaPanelWidth, setMediaPanelWidth])
897
+
898
+ /**
899
+ * Drag the divider. Bound to `document` rather than the handle so the drag
900
+ * survives the pointer outrunning a 5px target — the same reason the timeline's
901
+ * own gestures listen on the document.
902
+ *
903
+ * The clamp has two jobs: keep the timeline usable (`MIN_TIMELINE_PANE_PX`),
904
+ * and always leave the preview a real area to draw in, so the divider can
905
+ * never be dragged to the top of the window and strand the picture at zero
906
+ * height. `document.body.style.cursor` holds the resize cursor for the whole
907
+ * drag, otherwise it flickers back whenever the pointer crosses a child that
908
+ * sets its own.
909
+ */
910
+ const startSplitDrag = useCallback((e: React.MouseEvent) => {
911
+ e.preventDefault()
912
+ const startY = e.clientY
913
+ const startHeight = timelinePaneHeight
914
+ const available = splitRef.current?.getBoundingClientRect().height ?? 0
915
+ const max = Math.max(MIN_TIMELINE_PANE_PX, Math.min(MAX_TIMELINE_PANE_PX, available - MIN_PREVIEW_PANE_PX))
916
+
917
+ let latest = startHeight
918
+ const onMove = (ev: MouseEvent) => {
919
+ latest = Math.max(MIN_TIMELINE_PANE_PX, Math.min(max, startHeight - (ev.clientY - startY)))
920
+ setTimelinePaneHeight(latest, { persist: false })
921
+ }
922
+ const onUp = () => {
923
+ document.removeEventListener('mousemove', onMove)
924
+ document.removeEventListener('mouseup', onUp)
925
+ document.body.style.cursor = ''
926
+ document.body.style.userSelect = ''
927
+ setTimelinePaneHeight(latest) // the write that actually persists
928
+ }
929
+ document.addEventListener('mousemove', onMove)
930
+ document.addEventListener('mouseup', onUp)
931
+ document.body.style.cursor = 'row-resize'
932
+ document.body.style.userSelect = 'none'
933
+ }, [timelinePaneHeight, setTimelinePaneHeight])
934
+
935
+ // The hovered frame, in an external store so a mousemove repaints the preview
936
+ // and nothing else — see `hover-scrub.ts` for why this is not the clock.
937
+ const hoverScrubRef = useRef(createHoverScrub())
938
+ const hoverScrub = hoverScrubRef.current
939
+
940
+ // Any real playhead movement cancels the hover preview: a playback tick, an
941
+ // arrow-key step, a scrubber drag, a click that seeks. Without this a hover
942
+ // left standing would pin the preview to a frame the playhead has since left,
943
+ // and pressing Space would play audio against a frozen picture.
944
+ useEffect(() => clock.subscribe(() => hoverScrub.set(null)), [clock, hoverScrub])
945
+
946
+ const handleHoverScrub = useCallback((time: number | null) => {
947
+ // Never while playing: the playback hooks drive the frame themselves, and
948
+ // an override would fight them for the <video>/engine's seek position.
949
+ if (time !== null && transportRef.current?.isPlaying()) return
950
+ hoverScrub.set(time)
951
+ }, [hoverScrub])
402
952
  const [showControls, setShowControls] = useState(false)
403
953
  // Source-crop mode: when on, the VideoSourceCropModal opens for the selected
404
954
  // tracks[0] video item. Cleared when selection changes.
405
955
  const [cropMode, setCropMode] = useState(false)
406
956
  const [renderOpen, setRenderOpen] = useState(false)
407
957
  const [regenCaptionsOpen, setRegenCaptionsOpen] = useState(false)
408
- // The clip/audio inspector target — derived from the timeline's inspect
409
- // callbacks. A Montaj-agnostic { kind, id } selector, not a project entity.
410
- const [inspecting, setInspecting] = useState<{ kind: 'clip' | 'audio'; id: string } | null>(null)
958
+ // Opens the caption-regeneration modal — CaptionListPanel's toolbar button.
959
+ // `onRegenerateCaptions` (host prop) wins when provided: a host that passes
960
+ // it is asserting it owns the caption job (running it as a background task
961
+ // instead of this component's blocking modal), so its trigger takes over
962
+ // even though `adapter.generateCaptions` is also present — the host uses
963
+ // that same adapter method to actually run the job it owns. Otherwise, the
964
+ // built-in modal path is provided only when the host adapter supports
965
+ // `generateCaptions`; absent → the "Regenerate" button is hidden there.
966
+ const handleRegenerateCaptions =
967
+ onRegenerateCaptions ?? (adapter.generateCaptions ? () => setRegenCaptionsOpen(true) : undefined)
968
+ const [polishOpen, setPolishOpen] = useState(false)
969
+ // Opens AudioPolishModal — toolbar button and command palette entry.
970
+ // Provided only when the host adapter supports `analyzeAudioPolish`; absent →
971
+ // neither entry point renders, exactly like `handleRegenerateCaptions` above.
972
+ const handleAudioPolish = adapter.analyzeAudioPolish ? () => setPolishOpen(true) : undefined
973
+ // SP5 T9 — command palette (Cmd/Ctrl+K). `'goto'` opens straight into the
974
+ // timecode input (the scrubber's time-readout click); `'list'` opens the
975
+ // filtered command list.
976
+ const [paletteOpen, setPaletteOpen] = useState<false | 'list' | 'goto'>(false)
977
+
978
+ // ── T9 keymap plumbing (continued after `anyModalOpen`, below) ──
979
+ // The transport seam — filled by PreviewPlayer from whichever playback path
980
+ // (legacy or engine) is active. The keymap and palette use it for play/
981
+ // pause; the shuttle polls `isPlaying()` to detect a real transport change.
982
+ const transportRef = useRef<TransportHandle | null>(null)
983
+ // The audible drag-scrub source's engine seam — mirrors `transportRef`.
984
+ // Filled by PreviewPlayer only on the WebCodecs engine path; stays null on
985
+ // the legacy `<video>` fallback (see the scrub-source effect below).
986
+ const scrubHandleRef = useRef<ScrubHandle | null>(null)
987
+ // Marker/zoom actions Timeline exposes for the palette, mirroring
988
+ // `transportRef`'s shape.
989
+ const timelineActionsRef = useRef<TimelineActions | null>(null)
990
+
991
+ // Declared ahead of the scrub effect below (rather than alongside
992
+ // currentSocialPreview/currentImageTone further down) because that effect
993
+ // reads it to seed the scrubber's initial enabled state.
994
+ const currentAudibleScrub = project.settings?.audibleScrub ?? false
995
+
996
+ // Audible drag-scrub: one grain-per-move scrubber for the life of the
997
+ // surface, attached to the same hover store the preview reads. `resolve`
998
+ // re-checks `scrubHandleRef.current` on every call (not just at
999
+ // construction) so it stays silent — same as a gap or a canvas project —
1000
+ // until PreviewPlayer's own effect fills the ref, and goes silent again if
1001
+ // the project is on the legacy `<video>` fallback, which never fills it.
1002
+ const scrubberRef = useRef<ScrubSource | null>(null)
1003
+ useEffect(() => {
1004
+ const resolve = createScrubResolver(() => sync.projectRef.current)
1005
+ const scrubber = createScrubSource({
1006
+ acquireDemux: (src) => scrubHandleRef.current!.acquireDemux(src),
1007
+ resolve: (projectS) => (scrubHandleRef.current ? resolve(projectS) : null),
1008
+ onError: (message) => console.error('[montaj] scrub-source:', message),
1009
+ })
1010
+ scrubber.setEnabled(currentAudibleScrub)
1011
+ scrubberRef.current = scrubber
1012
+ const detach = scrubber.attach(hoverScrub)
1013
+ return () => {
1014
+ detach()
1015
+ scrubber.dispose()
1016
+ scrubberRef.current = null
1017
+ }
1018
+ }, [hoverScrub, sync.projectRef])
1019
+
1020
+ // Flip the live scrubber's enabled flag when the settings toggle changes,
1021
+ // without tearing down and recreating it — `setEnabled` also stops any
1022
+ // in-flight grain on disable (scrub-source.ts).
1023
+ useEffect(() => {
1024
+ scrubberRef.current?.setEnabled(currentAudibleScrub)
1025
+ }, [currentAudibleScrub])
1026
+
1027
+ // Duration for shuttle clamping and the palette's "go to time" clamp —
1028
+ // read fresh off the sync core's project ref rather than captured once, so
1029
+ // neither goes stale across edits.
1030
+ const getTotalDuration = useCallback(
1031
+ () => computeDerivedTiming(sync.projectRef.current).totalDuration,
1032
+ [sync.projectRef],
1033
+ )
1034
+
1035
+ // J/K/L shuttle. Created once (lazy ref init) — its deps are all stable
1036
+ // (clock, transportRef, the duration getter above), so there's nothing to
1037
+ // recreate it over. See shuttle.ts for the rate-stepping/cancellation design.
1038
+ const shuttleRef = useRef<ReturnType<typeof createShuttleController> | null>(null)
1039
+ if (!shuttleRef.current) {
1040
+ shuttleRef.current = createShuttleController({
1041
+ clock,
1042
+ getDuration: getTotalDuration,
1043
+ isPlaying: () => transportRef.current?.isPlaying() ?? false,
1044
+ play: () => { if (!transportRef.current?.isPlaying()) transportRef.current?.togglePlay() },
1045
+ pause: () => { if (transportRef.current?.isPlaying()) transportRef.current.togglePlay() },
1046
+ setRate: (rate) => transportRef.current?.setRate(rate),
1047
+ })
1048
+ }
1049
+ const shuttle = shuttleRef.current
1050
+ // The loop is rAF-driven and neither of its cancellation guards fires after
1051
+ // unmount (nothing is playing, and nothing else writes the clock), so stop it
1052
+ // explicitly rather than let it run out the timeline against a dead clock.
1053
+ useEffect(() => () => shuttle.stop(), [shuttle])
411
1054
 
412
1055
  // Render trigger — marks the project final, saves, and opens the RenderModal.
413
1056
  // Kept stable (the sync mutators/ref are stable; `emit` read via ref) so a host
@@ -427,6 +1070,42 @@ function ReviewSurface<P extends Project>({
427
1070
  })
428
1071
  }, [syncMutate, syncProjectRef])
429
1072
 
1073
+ // Persist the audible drag-scrub toggle into project settings — same
1074
+ // save-then-sync idiom as handleImageToneChange above. SET-always (unlike
1075
+ // handleSocialPreviewChange's omit-key below): a boolean has no natural
1076
+ // "unset" state, and default-off means an explicit `true` has to persist
1077
+ // the operator's opt-in.
1078
+ const handleAudibleScrubChange = useCallback((on: boolean) => {
1079
+ void syncMutate(() => {
1080
+ const cur = syncProjectRef.current
1081
+ return { ...cur, settings: { ...cur.settings, audibleScrub: on } } as P
1082
+ })
1083
+ }, [syncMutate, syncProjectRef])
1084
+
1085
+ // Persist the social-media preview platform into project settings — same
1086
+ // shape as handleImageToneChange above. `null` clears it (the picker's
1087
+ // "None" entry), which the settings-spread below has to do by explicitly
1088
+ // OMITTING the key rather than writing `socialPreview: undefined`: a
1089
+ // spread with an `undefined` value still enumerates the key, so a
1090
+ // JSON-serialized save would round-trip it back as `null`/absent
1091
+ // inconsistently across hosts — omitting it keeps "no selection" and
1092
+ // "field never written" the same on-disk shape.
1093
+ const handleSocialPreviewChange = useCallback((platform: SocialPreviewPlatform | null) => {
1094
+ void syncMutate(() => {
1095
+ const cur = syncProjectRef.current
1096
+ const { socialPreview: _drop, ...restSettings } = cur.settings
1097
+ return {
1098
+ ...cur,
1099
+ settings: platform ? { ...restSettings, socialPreview: platform } : restSettings,
1100
+ } as P
1101
+ })
1102
+ }, [syncMutate, syncProjectRef])
1103
+ const currentSocialPreview = project.settings?.socialPreview ?? null
1104
+ // The active pick's glyph/badge, for the controls-row trigger button
1105
+ // (see its render site below) — `null` on "None", same as `platformOption`
1106
+ // itself returns for a `null` platform.
1107
+ const activeSocialPreviewOption = platformOption(currentSocialPreview)
1108
+
430
1109
  // Host-chrome placement of the image-tone setting (mirrors
431
1110
  // onProvideRenderTrigger): push the current state up whenever it changes,
432
1111
  // and null for SDR projects so the host hides the control.
@@ -437,6 +1116,76 @@ function ReviewSurface<P extends Project>({
437
1116
  onProvideImageTone(isHdrProject ? { value: currentImageTone ?? 'vivid', set: handleImageToneChange } : null)
438
1117
  }, [onProvideImageTone, isHdrProject, currentImageTone, handleImageToneChange])
439
1118
 
1119
+ // Pre-render options for the export dialog (RenderModal). `keeps` are the
1120
+ // track-0 video windows the modal samples cover/thumbnail frames from —
1121
+ // memoized so the modal sees a stable list. `name`/`durationSec` seed the
1122
+ // dialog's Name field and duration footer (duration = the video spine's last
1123
+ // frame, which defines the render length). `isHdr` gates the HDR-only controls
1124
+ // (export format, image color, SDR tone curve); the dialog itself now opens for
1125
+ // every project the montaj editor renders.
1126
+ //
1127
+ // Enabled tracks only (`enabledTrackItems`, not `trackItems`): render.js
1128
+ // computes the exported duration and poster frames from the same enabled
1129
+ // family, so a skipped base track must not leave this dialog advertising a
1130
+ // duration or cover/thumbnail windows the exported file won't actually have.
1131
+ const renderKeeps = useMemo(
1132
+ () => (enabledTrackItems(project)[0] ?? [])
1133
+ .filter(item => item.type === 'video')
1134
+ .map(item => ({ start: item.start, end: item.end })),
1135
+ [project.tracks],
1136
+ )
1137
+ // Persist the export resolution / fps tier into project settings — same
1138
+ // save-then-sync idiom as handleImageToneChange above. Mutating
1139
+ // settings.resolution to a same-aspect higher tier doesn't perturb the
1140
+ // editor preview (design-canvas only reads it for aspect), so this is safe
1141
+ // to fire straight from the export dialog's tier picker.
1142
+ const handleExportResolutionChange = useCallback((res: [number, number]) => {
1143
+ void syncMutate(() => {
1144
+ const cur = syncProjectRef.current
1145
+ return { ...cur, settings: { ...cur.settings, resolution: res } } as P
1146
+ })
1147
+ }, [syncMutate, syncProjectRef])
1148
+
1149
+ const handleExportFpsChange = useCallback((fps: number) => {
1150
+ void syncMutate(() => {
1151
+ const cur = syncProjectRef.current
1152
+ return { ...cur, settings: { ...cur.settings, fps } } as P
1153
+ })
1154
+ }, [syncMutate, syncProjectRef])
1155
+
1156
+ // Source-capped tier lists for the export dialog's resolution/fps pickers.
1157
+ // Memoized separately from preRenderOptions so a project mutation unrelated
1158
+ // to tracks/resolution/fps doesn't recompute the (mildly more expensive)
1159
+ // source-scan in availableResolutionTiers.
1160
+ const availableRes = useMemo(() => availableResolutionTiers(project), [project])
1161
+ const availableFpsList = useMemo(() => availableFpsTiers(project), [project])
1162
+
1163
+ const preRenderOptions = useMemo(() => ({
1164
+ isHdr: isHdrProject,
1165
+ keeps: renderKeeps,
1166
+ imageTone: { value: currentImageTone ?? 'vivid', set: handleImageToneChange },
1167
+ name: project.name?.trim() || undefined,
1168
+ durationSec: renderKeeps.reduce((m, k) => Math.max(m, k.end), 0),
1169
+ aspectRatio: (() => {
1170
+ const r = project.settings?.resolution
1171
+ return r && r[0] > 0 && r[1] > 0 ? r[0] / r[1] : undefined
1172
+ })(),
1173
+ resolution: {
1174
+ value: currentResolutionTier(project) ?? project.settings.resolution,
1175
+ available: availableRes,
1176
+ set: handleExportResolutionChange,
1177
+ },
1178
+ fps: {
1179
+ value: maxExportFps(project),
1180
+ available: availableFpsList,
1181
+ set: handleExportFpsChange,
1182
+ },
1183
+ }), [
1184
+ isHdrProject, renderKeeps, currentImageTone, handleImageToneChange,
1185
+ project.name, project.settings?.resolution, project.settings?.fps,
1186
+ availableRes, availableFpsList, handleExportResolutionChange, handleExportFpsChange,
1187
+ ])
1188
+
440
1189
  const openRender = useCallback(() => {
441
1190
  const final = { ...syncProjectRef.current, status: 'final' } as P
442
1191
  emitRef.current(final)
@@ -445,7 +1194,11 @@ function ReviewSurface<P extends Project>({
445
1194
  }, [syncMutate, syncProjectRef])
446
1195
  useEffect(() => { onProvideRenderTrigger?.(openRender) }, [onProvideRenderTrigger, openRender])
447
1196
 
448
- const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
1197
+ const { versions, restoring, setRestoring, saving, setSaving, refresh: refreshVersions } = useVersionHistory(adapter, project)
1198
+ // The version-compare view: the LEFT hash it was opened for, or null when
1199
+ // closed. Mirrors PendingSurface's `compareOpen` — each surface mounts its
1200
+ // own VersionPanel, so each owns its own compare state.
1201
+ const [compareOpen, setCompareOpen] = useState<string | null>(null)
449
1202
 
450
1203
  // Repair caption segments whose words[] text has diverged from edited seg.text.
451
1204
  // Inline caption edits update seg.text but not seg.words; this normalizes the
@@ -479,8 +1232,19 @@ function ReviewSurface<P extends Project>({
479
1232
  sync.applyExternal({ ...project, captions: repaired } as P)
480
1233
  }, [project.id, project.captions])
481
1234
 
482
- const clips = project.tracks?.[0] ?? []
483
- const hasContent = clips.length > 0 || (project.tracks?.slice(1).flat().length ?? 0) > 0 || (project.captions?.segments?.length ?? 0) > 0
1235
+ const clips = trackItems(project)[0] ?? []
1236
+ const hasContent = clips.length > 0 || (trackItems(project).slice(1).flat().length ?? 0) > 0 || (project.captions?.segments?.length ?? 0) > 0
1237
+
1238
+ // Preview controls row's timecode readout. `currentTime` is the same
1239
+ // `usePlaybackTime(clock)` subscription `CaptionListPanelWithClock` uses
1240
+ // lower down — a second, independent call here rather than threading its
1241
+ // value up, since it's a plain hook and this is a different render leaf.
1242
+ // `previewDuration` is the actual playable length (contentDuration), NOT
1243
+ // `getTotalDuration()`'s zoom/scroll-padded value used for shuttle/seek
1244
+ // clamping — that one runs ~20% past the last clip so the timeline canvas
1245
+ // has room to drag into, which would read as a wrong total here.
1246
+ const currentTime = usePlaybackTime(clock)
1247
+ const previewDuration = useMemo(() => computeDerivedTiming(project).contentDuration, [project])
484
1248
 
485
1249
  // The selected tracks[0] video item, if any — the only thing source-crop mode
486
1250
  // can target. Source crop is a tracks[0]-video primitive (the renderer applies
@@ -494,82 +1258,261 @@ function ReviewSurface<P extends Project>({
494
1258
  if (!cropTarget && cropMode) setCropMode(false)
495
1259
  }, [cropTarget, cropMode])
496
1260
 
497
- // Overlay props dialog — opened from the preview (double-click), the controls
498
- // bar, or the timeline block. VideoEditor owns the state so all three surfaces
499
- // share one modal. Edits ride the sync core's transient/commit gesture path
500
- // (live preview + one undo step on Save).
501
- const [editingOverlayId, setEditingOverlayId] = useState<string | null>(null)
502
- // Project snapshot taken when the dialog opens, so Cancel reverts to the
503
- // pre-edit state even though edits preview live in between.
504
- const editOriginalRef = useRef<P | null>(null)
505
- const requestEditOverlay = useCallback((id: string) => {
506
- editOriginalRef.current = syncProjectRef.current
507
- setEditingOverlayId(id)
508
- }, [syncProjectRef])
509
- const allVisualItems = (project.tracks ?? []).flat()
510
- const editingOverlayItem = editingOverlayId
511
- ? allVisualItems.find(i => i.id === editingOverlayId) ?? null
512
- : null
1261
+ // Overlay content editing used to be a floating dialog VideoEditor owned the
1262
+ // open/closed state for, shared by the preview double-click, the controls bar
1263
+ // and the timeline block. It is the right column's **Content** tab now (see
1264
+ // `propertiesPanel` below), which is never "open": selecting an overlay IS
1265
+ // opening it. So there is no `editingOverlayId`, and no pre-open project
1266
+ // snapshot for a Cancel to restore — undo is the revert path, and it works
1267
+ // because each committed gesture is exactly one undo step. Requesting an
1268
+ // edit is therefore just selecting the overlay. Replaces the whole selection
1269
+ // rather than extending it, matching a plain non-additive click: the panel
1270
+ // renders `primarySelectedId`, so leaving a previous id in front of this one
1271
+ // would double-click an overlay and show a different item's properties.
1272
+ const selectOverlayForEditing = useCallback((id: string) => {
1273
+ setSelectedIds([id])
1274
+ }, [])
1275
+ const allVisualItems = trackItems(project).flat()
1276
+
1277
+ // T9 keymap plumbing: every dialog/panel this surface can have open, ORed
1278
+ // into one flag so the keymap (and Timeline's own arrows/delete/enter/
1279
+ // escape keymap, via the `modalOpen` prop passed to it below) suppresses
1280
+ // every binding while any of them is up — including the palette itself, so
1281
+ // typing in a filter field elsewhere can't leak into a single-key
1282
+ // shortcut. There is no general "is a modal open" concept anywhere else in
1283
+ // the codebase (today's handlers didn't check this at all); this derives
1284
+ // it from state ReviewSurface already owns rather than inventing new
1285
+ // cross-file plumbing. The overlay props dialog used to be a term here; the
1286
+ // Content tab that replaced it is a PANEL, and a panel must not suppress the
1287
+ // timeline's own keys the way a modal does.
1288
+ const anyModalOpen = renderOpen || regenCaptionsOpen || polishOpen
1289
+ || showControls || cropMode || !!paletteOpen
513
1290
 
514
1291
  function withItemProps(base: P, id: string, nextProps: Record<string, unknown>): P {
515
1292
  return {
516
1293
  ...base,
517
- tracks: (base.tracks ?? []).map(track =>
518
- track.map(item => (item.id !== id ? item : { ...item, props: nextProps })),
1294
+ tracks: mapTrackItems(base, items =>
1295
+ items.map(item => (item.id !== id ? item : { ...item, props: nextProps })),
519
1296
  ),
520
1297
  } as P
521
1298
  }
522
1299
  // Live preview: reflect the in-progress edit locally (transient — no save, no
523
1300
  // undo push) so the overlay re-renders as the operator tweaks. `commit()` on
524
- // Save persists the accumulated transient state as one undo step.
1301
+ // the field's blur persists the accumulated transient state as one undo step.
525
1302
  function previewOverlayProps(id: string, nextProps: Record<string, unknown>) {
526
1303
  sync.mutateTransient(p => withItemProps(p, id, nextProps))
527
1304
  }
528
- // Commit on Save: the last preview already applied the final props transiently,
529
- // so committing persists them and records one undo step (the pre-edit baseline).
1305
+ // Closes a Content-tab typing gesture (fired on the field's blur) as one undo
1306
+ // step + queued save: the last preview already applied the final props
1307
+ // transiently, so this only has to persist them. Identical in shape to
1308
+ // `commitOverlayInspectorChange` below — the two tabs of the same panel.
530
1309
  function commitOverlayEdit() {
531
1310
  void sync.commit()
532
- editOriginalRef.current = null
533
- setEditingOverlayId(null)
534
1311
  }
535
- // Cancel/Esc/close: discard the live preview by restoring the pre-edit snapshot
536
- // (no save, no undo push).
537
- function cancelOverlayEdit() {
538
- if (editOriginalRef.current) sync.applyExternal(editOriginalRef.current)
539
- editOriginalRef.current = null
540
- setEditingOverlayId(null)
541
- }
542
- // The primary-selected JSX overlay, if any — drives the controls-bar edit button.
1312
+ // The primary-selected JSX overlay, if any — the Content/Transform panel's target.
543
1313
  const selectedOverlayItem = primarySelectedId
544
1314
  ? allVisualItems.find(i => i.id === primarySelectedId && i.type === 'overlay' && !!i.src) ?? null
545
1315
  : null
546
1316
 
1317
+ // The right properties panel's non-overlay target, resolved from the SAME
1318
+ // primary selection `selectedOverlayItem` above uses and derived the same
1319
+ // way — against `trackItems(project)` for a visual item, then against
1320
+ // `project.audio.tracks`. Precedence is overlay → clip → audio: an overlay
1321
+ // IS a visual item, so the clip branch has to exclude `type === 'overlay'`
1322
+ // or a selected overlay would show clip properties instead of its Transform
1323
+ // inspector. Audio is reached only when the id names no visual item at all —
1324
+ // ids are unique across the project, so that is a plain fallback, not a
1325
+ // guess. Exactly one of {overlay, clip, audio, nothing} holds at a time.
1326
+ const selectedVisualItem = primarySelectedId
1327
+ ? allVisualItems.find(i => i.id === primarySelectedId) ?? null
1328
+ : null
1329
+ const selectedAudioTrack = primarySelectedId && !selectedVisualItem
1330
+ ? project.audio?.tracks.find(t => t.id === primarySelectedId) ?? null
1331
+ : null
1332
+ const clipSelection: ClipSelection =
1333
+ selectedVisualItem && selectedVisualItem.type !== 'overlay'
1334
+ ? { kind: 'clip', item: selectedVisualItem }
1335
+ : selectedAudioTrack
1336
+ ? { kind: 'audio', track: selectedAudioTrack }
1337
+ : null
1338
+
547
1339
  // Edits coming from the timeline (drag/move/track changes): route through the
548
1340
  // sync core — one undo step + queued save + rollback-on-failure.
1341
+ /**
1342
+ * A LIVE, uncommitted edit — one per mousemove of a timeline gesture (and per
1343
+ * tick of the caption font slider). Transient: applied locally, but no save
1344
+ * and no undo entry.
1345
+ *
1346
+ * This used to be a full `mutate`, which pushed an undo entry per move. A
1347
+ * single drag across the timeline therefore recorded dozens of them, and undo
1348
+ * walked the clip back a few pixels at a time instead of putting it where it
1349
+ * started. `commitTimelineEdit` closes the gesture with ONE entry covering
1350
+ * the whole motion.
1351
+ *
1352
+ * Sets `captionGestureRef` BEFORE the transient apply: the lane-
1353
+ * normalization effect in VideoEditor is a dep of `sync.project.captions`,
1354
+ * so it can fire off the very state update `mutateTransient` triggers here —
1355
+ * the flag has to already be true when that happens, or the first mousemove
1356
+ * of a cross-row caption drag collapses the hole lane and clears the sync
1357
+ * core's transient baseline (see the comment beside `captionGestureRef`).
1358
+ */
549
1359
  function handleProjectChange(p: Project) {
550
- void sync.mutate(() => p as P)
1360
+ captionGestureRef.current = true
1361
+ sync.mutateTransient(() => p as P)
551
1362
  }
552
1363
 
553
- function handleCut(cut: { start: number; end: number }) {
554
- void sync.mutate(p => {
555
- let updated = primarySelectedId
556
- ? applyCutToItem(p, primarySelectedId, cut)
557
- : applyCutToTracks(p, cut)
558
- if (rippleMode) updated = collapseGaps(updated)
559
- return updated as P
560
- })
561
- setSelectedIds([])
1364
+ /**
1365
+ * The end of a gesture: persist the accumulated transient state as one save
1366
+ * and one undo step, taken from the snapshot before the gesture's first move.
1367
+ *
1368
+ * Applies `p` transiently first rather than trusting the last preview to have
1369
+ * been identical — a few callers (ripple delete, auto-crossfade) compute the
1370
+ * final project themselves and hand it straight here, and a caller that DID
1371
+ * already preview it lands a no-op.
1372
+ *
1373
+ * Clears `captionGestureRef` BEFORE the transient apply, for the same
1374
+ * before-the-state-update reason `handleProjectChange` sets it: this commit
1375
+ * IS the point a cross-row drag's hole lane should collapse, and the lane-
1376
+ * normalization effect must see the flag already false when it re-fires off
1377
+ * this project update.
1378
+ *
1379
+ * Known limitation: a gesture that calls `handleProjectChange` but never
1380
+ * reaches a commit (e.g. the component unmounts mid-drag) leaves the flag
1381
+ * stuck true. The only cost is that lane normalization for an externally-
1382
+ * arrived sparse project (SSE, regen, a hand-authored project.json) waits
1383
+ * one more caption edit before it applies — the next real `commitTimelineEdit`
1384
+ * clears the flag and the effect catches up.
1385
+ */
1386
+ function commitTimelineEdit(p: Project) {
1387
+ captionGestureRef.current = false
1388
+ // Fold the auto-crossfade into the SAME commit as the gesture, so an audio
1389
+ // drag/trim that ends overlapping a neighbour lands as ONE undo step (the
1390
+ // move and its derived fade together) rather than the move here plus a
1391
+ // separate fade commit. Idempotent — a project needing no fade comes back
1392
+ // unchanged — so video moves and non-overlapping audio moves are untouched.
1393
+ const faded = computeAutoCrossfade(p) ?? p
1394
+ sync.mutateTransient(() => faded as P)
1395
+ void sync.commit()
562
1396
  }
563
1397
 
564
1398
  function handleOverlayChange(id: string, changes: OverlayChanges) {
565
1399
  void sync.mutate(p => ({
566
1400
  ...p,
567
- tracks: (p.tracks ?? []).map(track =>
568
- track.map(item => item.id !== id ? item : { ...item, ...changes })
1401
+ tracks: mapTrackItems(p, items =>
1402
+ items.map(item => item.id !== id ? item : { ...item, ...changes })
569
1403
  ),
570
1404
  } as P))
571
1405
  }
572
1406
 
1407
+ // OverlayInspector edits (T3.2). Unlike handleOverlayChange above, this
1408
+ // takes a WHOLE replacement item rather than a partial-changes patch:
1409
+ // OverlayInspector itself decides (via keyframeOps) whether an edit writes
1410
+ // a static scalar or drops a keyframe, and disabling keyframing has to
1411
+ // REMOVE `item.keyframes` entirely (see keyframeOps.withTrack) — something
1412
+ // a `{ ...item, ...changes }` merge can't express, since spreading in
1413
+ // `keyframes: undefined` leaves the key present rather than absent.
1414
+ // Shared with the clip properties panel below: a whole-item replacement is
1415
+ // exactly what that one needs too (a speed change rewrites `end` alongside
1416
+ // `speed`), so both inspectors write back through this.
1417
+ function replaceVisualItem(p: P, nextItem: VisualItem): P {
1418
+ return {
1419
+ ...p,
1420
+ tracks: mapTrackItems(p, items => items.map(item => (item.id === nextItem.id ? nextItem : item))),
1421
+ } as P
1422
+ }
1423
+ // Live preview for a continuously-typed number field: no undo entry, no
1424
+ // save yet. Mirrors previewOverlayProps below.
1425
+ function previewOverlayInspectorChange(nextItem: VisualItem) {
1426
+ sync.mutateTransient(p => replaceVisualItem(p, nextItem))
1427
+ }
1428
+ // Closes a typing gesture (fired on the field's blur) as one undo step +
1429
+ // queued save. Mirrors commitOverlayEdit below — the last preview already
1430
+ // applied the final value, so this only has to persist it.
1431
+ function commitOverlayInspectorChange() {
1432
+ void sync.commit()
1433
+ }
1434
+ // A discrete, already-final edit (the keyframe diamond toggle) — there is
1435
+ // no separate blur to commit on, so preview + commit fire back to back as
1436
+ // one user-visible action.
1437
+ function applyOverlayInspectorChange(nextItem: VisualItem) {
1438
+ previewOverlayInspectorChange(nextItem)
1439
+ commitOverlayInspectorChange()
1440
+ }
1441
+
1442
+ // Move the playhead to an absolute timeline time, clamped to the project —
1443
+ // the same clamp the command palette's "go to time" applies. `clock.set` is
1444
+ // the whole of "seek" in this surface: audible drag-scrub hangs off the
1445
+ // hover store, not the clock (a clock tick CANCELS a hover preview, see the
1446
+ // subscription above), so there is no extra side effect to route through.
1447
+ // OverlayInspector's keyframe-nav arrows are the only caller; without it
1448
+ // they render disabled.
1449
+ const seekTo = useCallback(
1450
+ (time: number) => clock.set(Math.max(0, Math.min(getTotalDuration(), time))),
1451
+ [clock, getTotalDuration],
1452
+ )
1453
+
1454
+ // ── Clip / audio properties-panel edits ────────────────────────────────────
1455
+ // The same preview/commit/change trio as the overlay inspector above, on the
1456
+ // same `sync` machinery — ClipPropertiesPanel is its sibling in the right
1457
+ // column and hands back a whole replacement item / track the same way.
1458
+
1459
+ /** Set by a preview that changed the selected clip's timeline DURATION
1460
+ * (today only a speed change does). Read and cleared by the commit below,
1461
+ * which is where ripple has to run. A ref, not state: it is gesture
1462
+ * bookkeeping, and flipping it must not re-render mid-drag. */
1463
+ const clipDurationChangedRef = useRef(false)
1464
+
1465
+ // Cleared whenever the selection changes. Today `SpeedControl` sets the flag
1466
+ // and commits atomically, so it cannot survive a gesture — but nothing in
1467
+ // the type system says so, and a future control that previews a duration
1468
+ // change WITHOUT committing (an abandoned drag, a discarded transient, a
1469
+ // selection change mid-gesture) would leave it set and ripple the timeline
1470
+ // on the next unrelated commit. Resetting here makes that impossible rather
1471
+ // than merely unlikely.
1472
+ useEffect(() => { clipDurationChangedRef.current = false }, [primarySelectedId])
1473
+
1474
+ function previewClipChange(nextItem: VisualItem) {
1475
+ const before = trackItems(sync.projectRef.current).flat().find(i => i.id === nextItem.id)
1476
+ if (before && (before.end - before.start) !== (nextItem.end - nextItem.start)) {
1477
+ clipDurationChangedRef.current = true
1478
+ }
1479
+ sync.mutateTransient(p => replaceVisualItem(p, nextItem))
1480
+ }
1481
+ function commitClipChange() {
1482
+ // Ripple, which the retired ClipInspectModal used to do itself: shrinking a
1483
+ // clip (a speed-up) leaves a gap, and with the magnet on the timeline
1484
+ // closes it. ClipPropertiesPanel cannot — it only ever sees the one item,
1485
+ // and `collapseGaps` moves that item's SIBLINGS — so the commit handler
1486
+ // owns it. Folded into the same transient gesture, so the speed change and
1487
+ // the gap it closed undo as one step.
1488
+ if (clipDurationChangedRef.current && rippleMode) {
1489
+ sync.mutateTransient(p => collapseGaps(p))
1490
+ }
1491
+ clipDurationChangedRef.current = false
1492
+ void sync.commit()
1493
+ }
1494
+ function applyClipChange(nextItem: VisualItem) {
1495
+ previewClipChange(nextItem)
1496
+ commitClipChange()
1497
+ }
1498
+
1499
+ function replaceAudioTrack(p: P, nextTrack: AudioTrack): P {
1500
+ return {
1501
+ ...p,
1502
+ audio: { ...p.audio, tracks: (p.audio?.tracks ?? []).map(t => (t.id === nextTrack.id ? nextTrack : t)) },
1503
+ } as P
1504
+ }
1505
+ function previewAudioChange(nextTrack: AudioTrack) {
1506
+ sync.mutateTransient(p => replaceAudioTrack(p, nextTrack))
1507
+ }
1508
+ function commitAudioChange() {
1509
+ void sync.commit()
1510
+ }
1511
+ function applyAudioChange(nextTrack: AudioTrack) {
1512
+ previewAudioChange(nextTrack)
1513
+ commitAudioChange()
1514
+ }
1515
+
573
1516
  // Commit a per-segment caption change (preview drag → offsetX/offsetY/scale).
574
1517
  // Routed through `makeCaptionEdit` so there is exactly one project-mutation
575
1518
  // path for caption edits — it addresses the segment by id and leaves the
@@ -581,22 +1524,80 @@ function ReviewSurface<P extends Project>({
581
1524
  makeCaptionEdit(segmentId, syncProjectRef.current, (p) => void syncMutate(() => p as P))(patch)
582
1525
  }, [syncProjectRef, syncMutate])
583
1526
 
584
- // Selecting a caption segment and selecting a normal timeline item are
585
- // mutually exclusive selection models — never show both sets of handles at
586
- // once (see CaptionTrackRow's file header). A caption can be selected from
587
- // either the preview (click the selection box) or the timeline's caption
588
- // row, so this wrapper — not Timeline — is the one place that must clear
589
- // `selectedIds` on every caption selection; Timeline's own
590
- // `handleSelectItem` handles the reverse (selecting an item clears this).
1527
+ // Delete one caption segment by id — CaptionListPanel's per-row trash
1528
+ // button. Captions have no "add" affordance (R4: they come from
1529
+ // transcription; Regenerate rebuilds the whole track), so this is the only
1530
+ // track-mutating action the sidebar list performs beyond per-segment
1531
+ // patches — narrow enough to get its own channel rather than stretching
1532
+ // `onCaptionEdit`'s whole-project-commit signature for a single id.
1533
+ const handleCaptionSegmentDelete = useCallback((segmentId: string) => {
1534
+ const base = syncProjectRef.current
1535
+ // Captured into a local rather than re-read as `base.captions` below: a
1536
+ // property narrowing does NOT survive into the `syncMutate` closure, since
1537
+ // the compiler cannot prove the property was not reassigned in between, so
1538
+ // the spread there would widen `style` back to optional and no longer
1539
+ // satisfy `Captions`.
1540
+ const captions = base.captions
1541
+ if (!captions) return
1542
+ const segments = captions.segments.filter(s => s.id !== segmentId)
1543
+ if (segments.length === captions.segments.length) return
1544
+ // `normalizeCaptionLanes` (same call Timeline.tsx's Delete keymap makes)
1545
+ // so deleting the LAST caption in a row collapses that hole lane in the
1546
+ // same commit, instead of persisting a sparse lane to disk that only the
1547
+ // canvas timeline's own Delete key used to close.
1548
+ void syncMutate(() => ({ ...base, captions: normalizeCaptionLanes({ ...captions, segments }) } as P))
1549
+ }, [syncProjectRef, syncMutate])
1550
+
1551
+ // Selecting a caption from the preview (click the selection box) is just
1552
+ // setting the unified selection — `selectedCaptionId` above is DERIVED from
1553
+ // `selectedIds`, so there is no second selection model left to keep in
1554
+ // sync. `id !== null` replaces the whole array with just that caption
1555
+ // (matching a plain, non-additive click anywhere else on the timeline);
1556
+ // `null` clears it.
591
1557
  const handleSelectCaption = useCallback((id: string | null) => {
592
- setSelectedCaptionId(id)
593
- if (id !== null) setSelectedIds([])
1558
+ setSelectedIds(id ? [id] : [])
594
1559
  }, [])
595
1560
 
1561
+ // Canvas double-click on a caption (Timeline's `onEditCaption`, Phase 6):
1562
+ // selects it exactly like a click would, AND asks the sidebar to scroll to
1563
+ // and focus that segment's text field — `nextEditFocus` (CaptionListPanel.tsx)
1564
+ // is what makes a second double-click on the same segment re-focus it (see
1565
+ // `editFocusId` above for why the bare id can't carry that signal alone).
1566
+ // Extracted to a pure, unit-tested function rather than inlined here, since
1567
+ // this increment is load-bearing and a source-level test alone can't prove
1568
+ // it does the right arithmetic, only that it looks like it does.
1569
+ const handleEditCaption = useCallback((id: string) => {
1570
+ handleSelectCaption(id)
1571
+ setEditFocusId(prev => nextEditFocus(prev, id))
1572
+ }, [handleSelectCaption])
1573
+
596
1574
  function handleSplit(at?: number) {
597
1575
  const base = syncProjectRef.current
598
- const updated = splitAtTime(base, at ?? clock.get(), primarySelectedId ?? null)
1576
+ const time = at ?? clock.get()
1577
+ // With nothing selected, split ONLY the main video track (tracks[0]) at
1578
+ // `time` — NOT every track under it. Passing `null` to `splitAtTime`
1579
+ // razors every visual track AND every audio track at once, which is not
1580
+ // what "Split with nothing selected" should do (Sam: main track only). So
1581
+ // resolve the base-track clip under `time` and scope the split to its id;
1582
+ // if nothing on the main track sits under `time`, there's nothing to split
1583
+ // — a no-op, rather than cutting overlays/audio too.
1584
+ let targetId = primarySelectedId ?? null
1585
+ if (targetId === null) {
1586
+ const mainItem = (trackItems(base)[0] ?? []).find(it => time > it.start && time < it.end)
1587
+ if (!mainItem) return
1588
+ targetId = mainItem.id
1589
+ }
1590
+ let updated = splitAtTime(base, time, targetId)
599
1591
  if (updated === base) return
1592
+ // `splitAtTime` reaches `applyCutToCaptions`, which can DROP a caption
1593
+ // segment at the cut instead of splitting it; dropping a row's last
1594
+ // caption leaves a hole lane, so densify in the same commit — same guard
1595
+ // `handleRippleDelete` uses, and free when nothing changed
1596
+ // (`normalizeCaptionLanes` returns the same reference).
1597
+ if (updated.captions) {
1598
+ const dense = normalizeCaptionLanes(updated.captions)
1599
+ if (dense !== updated.captions) updated = { ...updated, captions: dense } as P
1600
+ }
600
1601
  void sync.mutate(() => updated as P)
601
1602
  }
602
1603
 
@@ -610,22 +1611,305 @@ function ReviewSurface<P extends Project>({
610
1611
  }
611
1612
  }
612
1613
 
613
- // Keyboard: split (S), undo (cmd/ctrl-Z), redo (cmd/ctrl-shift-Z or cmd/ctrl-Y).
614
- // Guarded against text inputs.
1614
+ // Ripple-delete the primary selection (T8's `rippleDelete`) — Shift+Delete
1615
+ // and the palette's "Ripple-delete selection" entry. Goes through
1616
+ // `sync.mutate` directly (one undo step, one queued save), the same commit
1617
+ // path every other destructive edit in this surface uses (handleSplit,
1618
+ // handleRippleToggle above).
1619
+ function handleRippleDelete() {
1620
+ if (!primarySelectedId) return
1621
+ const base = syncProjectRef.current
1622
+ let updated = rippleDelete(base, primarySelectedId)
1623
+ if (updated === base) return
1624
+ // `rippleDelete` reaches `applyCutToCaptions`, which DROPS caption segments
1625
+ // falling inside the removed span rather than shifting them. Dropping the last
1626
+ // caption in a row leaves a hole lane, so densify in the same commit. Free when
1627
+ // nothing changed: `normalizeCaptionLanes` returns the same reference.
1628
+ if (updated.captions) {
1629
+ const dense = normalizeCaptionLanes(updated.captions)
1630
+ if (dense !== updated.captions) updated = { ...updated, captions: dense } as P
1631
+ }
1632
+ void sync.mutate(() => updated as P)
1633
+ setSelectedIds([])
1634
+ }
1635
+
1636
+ // Copy the current selection into `clipboardRef` (T2). Visual items and
1637
+ // audio tracks only — `copySelection` (clipboard-ops.ts) ignores caption
1638
+ // ids by construction. Not a `sync.mutate`: nothing about the project
1639
+ // changes, only local clipboard state.
1640
+ function handleCopy() {
1641
+ if (selectedIds.length === 0) return
1642
+ clipboardRef.current = copySelection(syncProjectRef.current, selectedIds)
1643
+ }
1644
+
1645
+ // Paste the clipboard at the playhead. One `sync.mutate`, same
1646
+ // `if (updated === base) return` no-op guard `handleRippleDelete` uses
1647
+ // above. `pasteAt` mints fresh ids for every pasted item, which
1648
+ // `collectAllIds` doesn't get back directly — diffing the id sets before
1649
+ // and after is what selects the newly pasted items afterward.
1650
+ function handlePaste() {
1651
+ const payload = clipboardRef.current
1652
+ if (!payload) return
1653
+ const base = syncProjectRef.current
1654
+ const beforeIds = collectAllIds(base)
1655
+ const updated = pasteAt(base, payload, clock.get())
1656
+ if (updated === base) return
1657
+ void sync.mutate(() => updated as P)
1658
+ setSelectedIds([...collectAllIds(updated)].filter(id => !beforeIds.has(id)))
1659
+ }
1660
+
1661
+ // Duplicate the current selection in place. Same commit + id-diff pattern
1662
+ // as `handlePaste` above.
1663
+ function handleDuplicate() {
1664
+ if (selectedIds.length === 0) return
1665
+ const base = syncProjectRef.current
1666
+ const beforeIds = collectAllIds(base)
1667
+ const updated = duplicateSelection(base, selectedIds)
1668
+ if (updated === base) return
1669
+ void sync.mutate(() => updated as P)
1670
+ setSelectedIds([...collectAllIds(updated)].filter(id => !beforeIds.has(id)))
1671
+ }
1672
+
1673
+ // Copy the clipboard's "look" attributes onto every selected item
1674
+ // (`pasteAttributes`, clipboard-ops.ts — type-gated per item/track kind).
1675
+ // No selection change: unlike paste/duplicate this never creates items.
1676
+ function handlePasteAttributes() {
1677
+ const payload = clipboardRef.current
1678
+ if (!payload || selectedIds.length === 0) return
1679
+ const base = syncProjectRef.current
1680
+ const updated = pasteAttributes(base, payload, selectedIds)
1681
+ if (updated === base) return
1682
+ void sync.mutate(() => updated as P)
1683
+ }
1684
+
1685
+ const openPalette = useCallback(() => setPaletteOpen('list'), [])
1686
+ const openGoToTime = useCallback(() => setPaletteOpen('goto'), [])
1687
+ const closePalette = useCallback(() => setPaletteOpen(false), [])
1688
+
1689
+ // Fullscreen preview (T5). `previewRegionRef` is attached to the
1690
+ // `previewRegion` wrapper div below — the ONE shared node both editor
1691
+ // layouts (CapCut and classic) render, so a single ref/toggle covers both.
1692
+ // `isFullscreen` is kept in sync with the REAL fullscreen state via the
1693
+ // `fullscreenchange` listener, not just set optimistically on toggle: the
1694
+ // browser can exit fullscreen on its own (Escape, tab switch) without ever
1695
+ // calling `toggleFullscreen`, and the button/palette label would otherwise
1696
+ // go stale. No Escape handling of our own — that's native.
1697
+ const previewRegionRef = useRef<HTMLDivElement | null>(null)
1698
+ const [isFullscreen, setIsFullscreen] = useState(false)
615
1699
  useEffect(() => {
616
- const onKey = (e: KeyboardEvent) => {
617
- const el = e.target as HTMLElement
618
- if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable) return
619
- if (e.key === 's' || e.key === 'S') { e.preventDefault(); handleSplit(); return }
620
- const mod = e.metaKey || e.ctrlKey
621
- if (!mod) return
622
- const key = e.key.toLowerCase()
623
- if (key === 'z' && !e.shiftKey) { e.preventDefault(); sync.undo() }
624
- else if ((key === 'z' && e.shiftKey) || key === 'y') { e.preventDefault(); sync.redo() }
1700
+ const onFullscreenChange = () => setIsFullscreen(document.fullscreenElement === previewRegionRef.current)
1701
+ document.addEventListener('fullscreenchange', onFullscreenChange)
1702
+ return () => document.removeEventListener('fullscreenchange', onFullscreenChange)
1703
+ }, [])
1704
+ const toggleFullscreen = useCallback(() => {
1705
+ if (document.fullscreenElement) void document.exitFullscreen()
1706
+ else void previewRegionRef.current?.requestFullscreen()
1707
+ }, [])
1708
+
1709
+ // Social-media preview chrome (mirrors CapCut's "Preview your video for
1710
+ // social media" picker) — a viewing aid only, off ("None") by default.
1711
+ // Drawn inside the aspect-ratio box (over the video) rather than the
1712
+ // controls row below it, by SocialSafeZoneOverlay itself. Persisted into
1713
+ // project settings (see handleSocialPreviewChange below) the same way
1714
+ // handleImageToneChange persists the HDR image-tone pick — a real user
1715
+ // preference, not per-render state, so it survives a reload.
1716
+ const [socialPreviewMenuOpen, setSocialPreviewMenuOpen] = useState(false)
1717
+ const socialPreviewTriggerRef = useRef<HTMLButtonElement>(null)
1718
+
1719
+ // Command palette. Bindings for split/undo/redo/ripple-delete/palette-open
1720
+ // double as their own registry entries here; a few are palette-only
1721
+ // (zoom-fit, go-to-time, marker set/clear) — see `paletteCommands` below.
1722
+ // Cmd/Ctrl+K, and the J/K/L shuttle, are new to T9 and only ever lived at
1723
+ // this ReviewSurface level (same scope split/undo/redo already had — the
1724
+ // pending surface has no editing chrome).
1725
+ useKeymap([
1726
+ {
1727
+ id: 'video.split',
1728
+ description: 'Split at the playhead, or the preview axis when it is on',
1729
+ keyHint: ['S'],
1730
+ matches: matchesKey('s'),
1731
+ // When the preview axis (⌘A) is on, `S` splits at the AXIS time
1732
+ // (`hoverScrub`) rather than the playhead — split where you're looking,
1733
+ // as CapCut does. Falls back to the playhead when the axis is off or
1734
+ // nothing is being hovered (`get()` is null). Targeting is unchanged:
1735
+ // `handleSplit` → `splitAtTime(base, at, primarySelectedId ?? null)`
1736
+ // splits the selected item at that time, or the base track when nothing
1737
+ // is selected (a no-op if the axis isn't over the item).
1738
+ action: () => handleSplit(previewAxis ? (hoverScrub.get() ?? undefined) : undefined),
1739
+ },
1740
+ {
1741
+ id: 'video.undo',
1742
+ description: 'Undo',
1743
+ keyHint: ['⌘', 'Z'],
1744
+ matches: matchesUndo,
1745
+ action: () => sync.undo(),
1746
+ },
1747
+ {
1748
+ id: 'video.redo',
1749
+ description: 'Redo',
1750
+ keyHint: ['⌘', '⇧', 'Z'],
1751
+ matches: matchesRedo,
1752
+ action: () => sync.redo(),
1753
+ },
1754
+ {
1755
+ id: 'video.ripple-delete',
1756
+ description: 'Ripple-delete selection',
1757
+ keyHint: ['⇧', 'Delete'],
1758
+ matches: matchesShiftDelete,
1759
+ guard: () => !!primarySelectedId,
1760
+ action: () => handleRippleDelete(),
1761
+ },
1762
+ {
1763
+ // `preventDefault: false` so the browser's own text copy is untouched —
1764
+ // this only ever ADDS a project-level copy on top, it never replaces
1765
+ // whatever native selection copy would otherwise do.
1766
+ id: 'video.copy',
1767
+ description: 'Copy selection',
1768
+ keyHint: ['⌘', 'C'],
1769
+ matches: matchesModKey('c'),
1770
+ guard: () => selectedIds.length > 0,
1771
+ preventDefault: false,
1772
+ action: () => handleCopy(),
1773
+ },
1774
+ {
1775
+ // MUST be registered before `video.paste`: `matchesModKey('v')` does
1776
+ // NOT exclude `altKey`, so Cmd+Opt+V matches BOTH this binding and the
1777
+ // plain-paste one below — first match wins, so paste-attributes has to
1778
+ // come first or Cmd+Opt+V would silently fall through to a plain paste.
1779
+ // See keymap.ts's `matchesModAltKey` doc comment for the same note.
1780
+ id: 'video.paste-attributes',
1781
+ description: 'Paste attributes',
1782
+ keyHint: ['⌘', '⌥', 'V'],
1783
+ matches: matchesModAltKey('v'),
1784
+ guard: () => !!clipboardRef.current && selectedIds.length > 0,
1785
+ action: () => handlePasteAttributes(),
1786
+ },
1787
+ {
1788
+ id: 'video.paste',
1789
+ description: 'Paste',
1790
+ keyHint: ['⌘', 'V'],
1791
+ matches: matchesModKey('v'),
1792
+ guard: () => !!clipboardRef.current,
1793
+ action: () => handlePaste(),
1794
+ },
1795
+ {
1796
+ id: 'video.duplicate',
1797
+ description: 'Duplicate selection',
1798
+ keyHint: ['⌘', 'D'],
1799
+ matches: matchesModKey('d'),
1800
+ guard: () => selectedIds.length > 0,
1801
+ action: () => handleDuplicate(),
1802
+ },
1803
+ {
1804
+ id: 'video.fullscreen',
1805
+ description: 'Toggle fullscreen preview',
1806
+ keyHint: ['F'],
1807
+ matches: matchesPlainKey('f'),
1808
+ action: () => toggleFullscreen(),
1809
+ },
1810
+ {
1811
+ // A for Axis. CapCut binds this to plain `S`, which Split owns here —
1812
+ // and `video.split` is `matchesKey('s')`, a bare key test with no
1813
+ // modifier check (deliberately: it reproduces the pre-keymap split
1814
+ // handler verbatim), so any S-based chord would be swallowed by it.
1815
+ //
1816
+ // `matchesModKey` is meta-OR-ctrl (keymap.ts's `mod`), so this is Cmd+A
1817
+ // and Ctrl+A alike. That shadows the browser's Select All, which this
1818
+ // surface has no use for — and only outside a typing surface: every
1819
+ // binding sits behind `isTypingTarget`, so Cmd+A in a caption, an input,
1820
+ // or a textarea still selects text natively.
1821
+ id: 'video.preview-axis',
1822
+ description: 'Toggle preview axis',
1823
+ keyHint: ['⌘', 'A'],
1824
+ matches: matchesModKey('a'),
1825
+ action: () => setPreviewAxis(v => !v),
1826
+ },
1827
+ {
1828
+ id: 'video.open-palette',
1829
+ description: 'Open command palette',
1830
+ keyHint: ['⌘', 'K'],
1831
+ matches: matchesModKey('k'),
1832
+ guard: () => !paletteOpen,
1833
+ action: () => openPalette(),
1834
+ paletteHidden: true,
1835
+ },
1836
+ {
1837
+ id: 'video.shuttle-forward',
1838
+ description: 'Shuttle forward',
1839
+ keyHint: ['L'],
1840
+ matches: matchesPlainKey('l'),
1841
+ action: () => shuttle.press(1),
1842
+ paletteHidden: true,
1843
+ },
1844
+ {
1845
+ id: 'video.shuttle-backward',
1846
+ description: 'Shuttle backward',
1847
+ keyHint: ['J'],
1848
+ matches: matchesPlainKey('j'),
1849
+ action: () => shuttle.press(-1),
1850
+ paletteHidden: true,
1851
+ },
1852
+ {
1853
+ id: 'video.shuttle-stop',
1854
+ description: 'Stop shuttle',
1855
+ keyHint: ['K'],
1856
+ matches: matchesPlainKey('k'),
1857
+ action: () => shuttle.stop(),
1858
+ paletteHidden: true,
1859
+ },
1860
+ // No `modalOpen` gating here — split/undo/redo never had a modal guard
1861
+ // (see the file-header note above `anyModalOpen`), and RenderModal being
1862
+ // open mid-render is a real flow that still needs Cmd+Z to work (the
1863
+ // existing "undo restores the pre-mutation project" test drives exactly
1864
+ // that: click Render → RenderModal opens → Cmd+Z must still undo). The
1865
+ // typing-surface guard alone (shared by every binding, unconditionally)
1866
+ // already keeps these from firing while the palette's own filter input has
1867
+ // focus — modal-gating would only add protection for the edge case of a
1868
+ // dialog open with focus OUTSIDE it, which isn't worth the regression risk.
1869
+ ])
1870
+
1871
+ // The palette's command list — split/undo/redo/ripple-delete/play-pause
1872
+ // read live state so entries only appear when they'd actually do
1873
+ // something (no selection → no ripple-delete row; nothing to undo → no
1874
+ // undo row). Zoom-fit routes through `timelineActionsRef` (Timeline-local
1875
+ // state — see Timeline.tsx's `TimelineActions`). Roll/slip/
1876
+ // slide are drag gestures and deliberately have no palette variant.
1877
+ const paletteCommands: PaletteCommand[] = [
1878
+ { id: 'play-pause', label: 'Play/Pause', keyHint: ['Space'], run: () => transportRef.current?.togglePlay() },
1879
+ { id: 'split', label: 'Split at playhead', keyHint: ['S'], run: () => handleSplit() },
1880
+ ]
1881
+ if (primarySelectedId) {
1882
+ paletteCommands.push({ id: 'ripple-delete', label: 'Ripple-delete selection', keyHint: ['⇧', 'Delete'], run: () => handleRippleDelete() })
1883
+ }
1884
+ if (selectedIds.length > 0) {
1885
+ paletteCommands.push({ id: 'copy', label: 'Copy', keyHint: ['⌘', 'C'], run: () => handleCopy() })
1886
+ paletteCommands.push({ id: 'duplicate', label: 'Duplicate', keyHint: ['⌘', 'D'], run: () => handleDuplicate() })
1887
+ }
1888
+ if (clipboardRef.current) {
1889
+ paletteCommands.push({ id: 'paste', label: 'Paste', keyHint: ['⌘', 'V'], run: () => handlePaste() })
1890
+ if (selectedIds.length > 0) {
1891
+ paletteCommands.push({ id: 'paste-attributes', label: 'Paste attributes', keyHint: ['⌘', '⌥', 'V'], run: () => handlePasteAttributes() })
625
1892
  }
626
- document.addEventListener('keydown', onKey)
627
- return () => document.removeEventListener('keydown', onKey)
628
- }, [project, primarySelectedId, sync])
1893
+ }
1894
+ if (sync.canUndo) paletteCommands.push({ id: 'undo', label: 'Undo', keyHint: ['⌘', 'Z'], run: () => sync.undo() })
1895
+ if (sync.canRedo) paletteCommands.push({ id: 'redo', label: 'Redo', keyHint: ['⌘', '⇧', 'Z'], run: () => sync.redo() })
1896
+ paletteCommands.push({
1897
+ id: 'preview-axis',
1898
+ label: previewAxis ? 'Preview axis: turn off' : 'Preview axis: turn on',
1899
+ keyHint: ['⌘', 'A'],
1900
+ run: () => setPreviewAxis(v => !v),
1901
+ })
1902
+ paletteCommands.push({
1903
+ id: 'fullscreen',
1904
+ label: isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen',
1905
+ keyHint: ['F'],
1906
+ run: () => toggleFullscreen(),
1907
+ })
1908
+ paletteCommands.push({ id: 'zoom-fit', label: 'Zoom to fit', run: () => timelineActionsRef.current?.zoomFit() })
1909
+ paletteCommands.push({ id: 'goto', label: 'Go to time…', run: () => openGoToTime() })
1910
+ if (handleAudioPolish) {
1911
+ paletteCommands.push({ id: 'audio-polish', label: 'Polish audio…', run: () => handleAudioPolish() })
1912
+ }
629
1913
 
630
1914
  async function handleRestoreVersion(hash: string) {
631
1915
  if (!adapter.restoreVersion) return
@@ -638,17 +1922,52 @@ function ReviewSurface<P extends Project>({
638
1922
  console.error(e)
639
1923
  } finally {
640
1924
  setRestoring(null)
1925
+ await refreshVersions()
641
1926
  }
642
1927
  }
643
1928
 
644
- return (
645
- <div className="flex flex-col flex-1 overflow-hidden">
646
- {/* Work area — editor body + version rail, side by side */}
647
- <div className="flex flex-1 overflow-hidden min-h-0">
648
- {/* Main: preview + timeline */}
649
- <div className="flex flex-col flex-1 overflow-hidden">
650
- <div className="flex-1 flex items-center justify-center bg-black overflow-hidden p-2">
651
- {hasContent ? (
1929
+ async function handleSaveVersion(name?: string) {
1930
+ if (!adapter.saveVersion) return
1931
+ setSaving(true)
1932
+ try {
1933
+ await adapter.saveVersion(project.id, name)
1934
+ await refreshVersions()
1935
+ } catch (e) {
1936
+ console.error(e)
1937
+ } finally {
1938
+ setSaving(false)
1939
+ }
1940
+ }
1941
+
1942
+ function handleCompareVersion(hash: string) {
1943
+ setCompareOpen(hash)
1944
+ }
1945
+
1946
+ // ── Shared layout pieces ─────────────────────────────────────────────────
1947
+ // The preview, row divider, timeline pane and right rail are identical in both
1948
+ // layouts; only their arrangement differs (classic column vs. CapCut top-row +
1949
+ // full-width timeline). Factoring them into local render values keeps the
1950
+ // classic path byte-for-byte unchanged and lets the CapCut branch reuse the
1951
+ // exact same nodes rather than duplicating this JSX.
1952
+
1953
+ // The preview column: video area on top, a slim controls row on chrome
1954
+ // underneath it. `previewRegionRef` stays on this outermost node — it is
1955
+ // both the fullscreen target and the one shared node both editor layouts
1956
+ // render, and keeping the controls row INSIDE it means the row is still
1957
+ // reachable once fullscreened (a bare video with no chrome at all would
1958
+ // leave fullscreen viewers with no way back out except Escape).
1959
+ //
1960
+ // The fullscreen button used to be a 28px corner overlay on the video
1961
+ // itself (`absolute top-2 right-2`, bg-black/50 for contrast against
1962
+ // whatever frame happened to be playing) — invisible on bright footage
1963
+ // and competing with the picture. It now lives as a normal child of this
1964
+ // row, on chrome, sized and styled like every other row button below
1965
+ // (the track-controls bar's Ripple/Crop toggles) rather than floating.
1966
+ const previewRegion = (
1967
+ <div ref={previewRegionRef} className="flex-1 min-h-0 flex flex-col bg-black overflow-hidden">
1968
+ {hasContent ? (
1969
+ <>
1970
+ <div className="flex-1 min-h-0 flex items-center justify-center overflow-hidden p-2">
652
1971
  <div
653
1972
  className="relative h-full max-w-full"
654
1973
  style={{ aspectRatio: (() => { const [w, h] = getOverlayDesignCanvas(project.settings?.resolution); return `${w} / ${h}` })() }}
@@ -658,7 +1977,10 @@ function ReviewSurface<P extends Project>({
658
1977
  clock={clock}
659
1978
  selectedOverlayId={primarySelectedId ?? undefined}
660
1979
  onOverlayChange={handleOverlayChange}
661
- onEditOverlay={requestEditOverlay}
1980
+ // Double-click on the preview. It used to open the props
1981
+ // dialog; it SELECTS now, which is what puts the overlay's
1982
+ // props on screen in the right column's Content tab.
1983
+ onEditOverlay={selectOverlayForEditing}
662
1984
  compileOverlay={adapter.compileOverlay}
663
1985
  clearOverlayCache={adapter.clearOverlayCache}
664
1986
  watchFile={adapter.watchFile}
@@ -667,184 +1989,766 @@ function ReviewSurface<P extends Project>({
667
1989
  selectedCaptionId={selectedCaptionId ?? undefined}
668
1990
  onSelectCaption={handleSelectCaption}
669
1991
  onCaptionSegmentChange={handleCaptionSegmentChange}
1992
+ engine={engine}
1993
+ transportRef={transportRef}
1994
+ scrubHandleRef={scrubHandleRef}
1995
+ hoverScrub={hoverScrub}
1996
+ // Social-media preview chrome (mirrors CapCut's "Preview your
1997
+ // video for social media" picker) — it previews what platform
1998
+ // UI would sit ON TOP of the picture, so PreviewPlayer mounts
1999
+ // it INSIDE its own preview surface (same coordinate space AND
2000
+ // stacking context as the picture, rather than as a sibling
2001
+ // here) — see the `socialPreview` prop doc on
2002
+ // PreviewPlayerProps. "None" (null) by default; the component
2003
+ // itself no-ops on an unset/unknown platform.
2004
+ socialPreview={currentSocialPreview ?? undefined}
670
2005
  />
2006
+ {/* Footage-bin source scrub (opt-in). A paused <video> parked above the
2007
+ timeline preview, showing an OFF-TIMELINE clip's frame while the
2008
+ host hovers a bin card. Inert unless a host supplies `sourcePreview`
2009
+ AND sets a value — see SourcePreviewOverlay / source-preview.ts. */}
2010
+ <SourcePreviewOverlay store={sourcePreview} />
671
2011
  </div>
672
- ) : (
673
- <p className="text-[var(--editor-text)]/60 text-sm">No clips</p>
674
- )}
2012
+ </div>
2013
+ {/* Preview controls row — chrome, not video. Timecode readout on the
2014
+ left; zoom-to-fit, safe-zone preview and fullscreen on the right,
2015
+ styled like the track-controls bar's toggles below (Ripple/Crop):
2016
+ w-5 h-5 icon buttons, `aria-pressed` colouring for true toggles,
2017
+ a styled `Tooltip` on every button instead of a native `title=`. */}
2018
+ <div className="shrink-0 flex items-center gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
2019
+ <span
2020
+ data-testid="preview-timecode"
2021
+ className="mr-auto text-[10px] font-mono tabular-nums text-[var(--editor-text)]/60 select-none"
2022
+ >
2023
+ {/* The playhead can sit past `previewDuration` while parked in
2024
+ `getTotalDuration()`'s ~20% trailing headroom (drag room for
2025
+ the timeline canvas) — clamp the DISPLAYED current time only,
2026
+ so the readout never shows e.g. "1:10.0 / 1:00.0". The clock
2027
+ itself and the total are untouched. */}
2028
+ {formatTimecode(Math.min(currentTime, previewDuration))} / {formatTimecode(previewDuration)}
2029
+ </span>
2030
+ {/* Zoom-to-fit lives in the timeline zoom chrome ("fit" next to the
2031
+ +/- zoom buttons, Timeline.tsx) — no duplicate control here. */}
2032
+ <Tooltip label="Preview for social media">
2033
+ <button
2034
+ ref={socialPreviewTriggerRef}
2035
+ onClick={() => setSocialPreviewMenuOpen(v => !v)}
2036
+ aria-label="Preview for social media"
2037
+ aria-haspopup="menu"
2038
+ aria-expanded={socialPreviewMenuOpen}
2039
+ aria-pressed={currentSocialPreview !== null}
2040
+ className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
2041
+ currentSocialPreview !== null
2042
+ ? (timelineMode === 'light' ? 'text-sky-600 bg-sky-400/15 hover:bg-sky-400/25' : 'text-sky-400 bg-sky-400/15 hover:bg-sky-400/25')
2043
+ : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
2044
+ }`}
2045
+ >
2046
+ {/* Trigger reflects the active selection: the platform's own
2047
+ glyph (TikTok/YouTube/Instagram) once one is picked, the
2048
+ plain Smartphone icon otherwise. */}
2049
+ {activeSocialPreviewOption
2050
+ ? <PlatformGlyph icon={activeSocialPreviewOption.icon} badgeClassName={activeSocialPreviewOption.badgeClassName} size={14} />
2051
+ : <Smartphone size={12} />}
2052
+ </button>
2053
+ </Tooltip>
2054
+ {socialPreviewMenuOpen && (
2055
+ <SocialPreviewMenu
2056
+ anchorRef={socialPreviewTriggerRef}
2057
+ value={currentSocialPreview}
2058
+ onChange={handleSocialPreviewChange}
2059
+ onClose={() => setSocialPreviewMenuOpen(false)}
2060
+ mode={timelineMode}
2061
+ />
2062
+ )}
2063
+ <Tooltip label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'} keys={['F']}>
2064
+ <button
2065
+ onClick={toggleFullscreen}
2066
+ aria-label="Toggle fullscreen"
2067
+ aria-pressed={isFullscreen}
2068
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
2069
+ >
2070
+ {isFullscreen ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
2071
+ </button>
2072
+ </Tooltip>
2073
+ </div>
2074
+ </>
2075
+ ) : (
2076
+ <div className="flex-1 min-h-0 flex items-center justify-center p-2">
2077
+ <p className="text-[var(--editor-text)]/60 text-sm">No clips</p>
675
2078
  </div>
2079
+ )}
2080
+ </div>
2081
+ )
2082
+
2083
+ // The divider. `row-resize` plus a hairline that lights up on hover is the
2084
+ // whole affordance — a 5px hit area is comfortable to grab without stealing a
2085
+ // visible row from either pane. Double-click restores the default split.
2086
+ const rowDivider = (
2087
+ <div
2088
+ role="separator"
2089
+ aria-orientation="horizontal"
2090
+ aria-label="Resize timeline"
2091
+ onMouseDown={startSplitDrag}
2092
+ onDoubleClick={() => setTimelinePaneHeight(DEFAULT_TIMELINE_PANE_PX)}
2093
+ className="group shrink-0 h-[5px] cursor-row-resize bg-transparent"
2094
+ >
2095
+ <div className="h-px w-full bg-[var(--editor-border)] transition-colors group-hover:bg-[var(--editor-accent)]" />
2096
+ </div>
2097
+ )
2098
+
2099
+ // Timeline pane — the half the divider sizes.
2100
+ const timelinePane = (
2101
+ <div className="shrink-0 flex flex-col overflow-hidden" style={{ height: timelinePaneHeight }}>
676
2102
 
677
- {/* Track controls bar — info + undo/redo + split + ripple + render */}
678
- <div className="shrink-0 flex items-center justify-end gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
2103
+ {/* Track controls bar — controls + undo/redo + preview axis + ripple +
2104
+ crop + render. Split is deliberately NOT here: it is a one-key verb
2105
+ (S, listed in the controls modal), and a glyph for it only crowded a
2106
+ row whose other buttons are modes you can't type your way into.
2107
+ Every button carries a styled `Tooltip` instead of a native `title=`:
2108
+ the native one is delayed ~1s and rendered in OS chrome, which on a
2109
+ row of 12px glyphs meant the affordances were effectively unlabelled.
2110
+ `aria-label` stays on each button — the tooltip is a hover affordance,
2111
+ not an accessible name. Disabled buttons get `pointer-events-none` so
2112
+ hover still reaches the wrapper and can explain WHY they're disabled,
2113
+ and fade to 50% rather than 30%: at 30% a 12px glyph on this surface
2114
+ reads as ABSENT, and Crop is disabled whenever no clip is selected —
2115
+ which is most of the time, so the control looked like it had been
2116
+ removed rather than like it was waiting on a selection. */}
2117
+ <div className="shrink-0 flex items-center justify-end gap-1.5 px-3 py-1 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
2118
+ <Tooltip label="Controls & shortcuts" className="mr-auto">
679
2119
  <button
680
2120
  onClick={() => setShowControls(true)}
681
- title="Editor controls & shortcuts"
682
2121
  aria-label="Editor controls & shortcuts"
683
- className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] mr-auto"
2122
+ className="flex items-center gap-1 px-1.5 h-5 rounded transition-colors text-[var(--editor-text)]/75 bg-transparent hover:text-[var(--editor-text)] hover:bg-[var(--editor-text)]/10"
684
2123
  >
685
- <Info size={12} />
2124
+ <HelpCircle size={14} />
2125
+ <span className="text-[10px] leading-none">Controls</span>
686
2126
  </button>
2127
+ </Tooltip>
2128
+ <Tooltip label="Undo" keys={['⌘', 'Z']}>
687
2129
  <button
688
2130
  onClick={sync.undo}
689
2131
  disabled={!sync.canUndo}
690
- title="Undo (Cmd/Ctrl+Z)"
691
2132
  aria-label="Undo"
692
- className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-30 disabled:cursor-not-allowed"
2133
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-50 disabled:pointer-events-none"
693
2134
  >
694
2135
  <Undo2 size={12} />
695
2136
  </button>
2137
+ </Tooltip>
2138
+ <Tooltip label="Redo" keys={['⌘', '⇧', 'Z']}>
696
2139
  <button
697
2140
  onClick={sync.redo}
698
2141
  disabled={!sync.canRedo}
699
- title="Redo (Cmd/Ctrl+Shift+Z)"
700
2142
  aria-label="Redo"
701
- className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-30 disabled:cursor-not-allowed"
2143
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-50 disabled:pointer-events-none"
702
2144
  >
703
2145
  <Redo2 size={12} />
704
2146
  </button>
2147
+ </Tooltip>
2148
+ <Tooltip label={previewAxis ? 'Preview axis on — hover to preview' : 'Preview axis off'} keys={['⌘', 'A']}>
705
2149
  <button
706
- onClick={() => handleSplit()}
707
- title="Split at playhead (S) — selected item or all clips"
708
- className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
2150
+ onClick={() => setPreviewAxis(v => !v)}
2151
+ aria-label="Preview axis"
2152
+ aria-pressed={previewAxis}
2153
+ className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
2154
+ previewAxis
2155
+ ? (timelineMode === 'light' ? 'text-yellow-700 bg-yellow-400/15 hover:bg-yellow-400/25' : 'text-yellow-400 bg-yellow-400/15 hover:bg-yellow-400/25')
2156
+ : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
2157
+ }`}
709
2158
  >
710
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
711
- <line x1="6" y1="0" x2="6" y2="12" />
712
- <polyline points="3,3 6,6 9,3" />
713
- <polyline points="3,9 6,6 9,9" />
714
- </svg>
2159
+ <SeparatorVertical size={12} />
715
2160
  </button>
2161
+ </Tooltip>
2162
+ <Tooltip label={rippleMode ? 'Ripple on — gaps close' : 'Ripple: close the gap'}>
716
2163
  <button
717
2164
  onClick={handleRippleToggle}
718
- title={rippleMode ? 'Ripple mode on — edits close the gap' : 'Ripple mode off — edits leave a gap'}
2165
+ aria-label="Ripple mode"
719
2166
  aria-pressed={rippleMode}
720
2167
  className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
721
2168
  rippleMode
722
- ? 'text-teal-400 bg-teal-400/15 hover:bg-teal-400/25'
2169
+ ? (timelineMode === 'light' ? 'text-teal-600 bg-teal-400/15 hover:bg-teal-400/25' : 'text-teal-400 bg-teal-400/15 hover:bg-teal-400/25')
723
2170
  : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
724
2171
  }`}
725
2172
  >
726
2173
  <Magnet size={12} />
727
2174
  </button>
2175
+ </Tooltip>
2176
+ <Tooltip
2177
+ label={!cropTarget ? 'Select a clip to crop' : cropMode ? 'Exit crop' : 'Crop source'}
2178
+ >
728
2179
  <button
729
2180
  onClick={() => setCropMode(m => !m)}
730
2181
  disabled={!cropTarget}
731
- title={
732
- !cropTarget
733
- ? 'Select a video clip to crop its source'
734
- : cropMode ? 'Exit source crop' : 'Crop source — non-destructively crop the selected clip'
735
- }
2182
+ aria-label="Crop source"
736
2183
  aria-pressed={cropMode}
737
- className={`flex items-center justify-center w-5 h-5 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed ${
2184
+ className={`flex items-center justify-center w-5 h-5 rounded transition-colors disabled:opacity-50 disabled:pointer-events-none ${
738
2185
  cropMode
739
- ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
2186
+ ? (timelineMode === 'light' ? 'text-amber-600 bg-amber-400/15 hover:bg-amber-400/25' : 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25')
740
2187
  : 'text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]'
741
2188
  }`}
742
2189
  >
743
2190
  <Crop size={12} />
744
2191
  </button>
745
- {selectedOverlayItem && (
2192
+ </Tooltip>
2193
+ {/* The Pencil "Edit overlay" button used to sit here, opening the
2194
+ floating props dialog. Both are retired: selecting an overlay now
2195
+ shows its props in the right column's Content tab, so a button whose
2196
+ only job was "open the thing that is already open" had nothing left
2197
+ to do. */}
2198
+ {/* Image color mapping. HDR projects only (the tone has no effect on
2199
+ SDR renders). Hidden when the host surfaces the setting in its own
2200
+ chrome via onProvideImageTone, mirroring the Render button. */}
2201
+ {!onProvideImageTone && isHdrProject && (
2202
+ <ImageToneMenu
2203
+ value={currentImageTone}
2204
+ onChange={handleImageToneChange}
2205
+ mode={timelineMode}
2206
+ />
2207
+ )}
2208
+ {/* Audio polish — silence/fillers/loudness/voice cleanup. Hidden when the
2209
+ host adapter doesn't implement `analyzeAudioPolish`, exactly like the
2210
+ caption-regen entry point above. */}
2211
+ {handleAudioPolish && (
2212
+ <Tooltip label="Polish audio">
746
2213
  <button
747
- onClick={() => requestEditOverlay(selectedOverlayItem.id)}
748
- title="Edit overlay — text, colors, and other properties"
749
- aria-label="Edit overlay"
2214
+ onClick={handleAudioPolish}
2215
+ aria-label="Polish audio"
750
2216
  className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
751
2217
  >
752
- <Pencil size={12} />
2218
+ <Wand2 size={12} />
753
2219
  </button>
754
- )}
755
- {/* Image color mapping. HDR projects only (the tone has no effect on
756
- SDR renders). Hidden when the host surfaces the setting in its own
757
- chrome via onProvideImageTone, mirroring the Render button. */}
758
- {!onProvideImageTone && isHdrProject && (
759
- <ImageToneMenu
760
- value={currentImageTone}
761
- onChange={handleImageToneChange}
2220
+ </Tooltip>
2221
+ )}
2222
+ {/* Audible drag-scrub toggle — sits next to Polish audio as the
2223
+ other audio tool in this row (moved out of the preview's
2224
+ controls row, which is chrome, not editing tools). */}
2225
+ <Tooltip label={currentAudibleScrub ? 'Mute drag-scrub audio' : 'Unmute drag-scrub audio'}>
2226
+ <button
2227
+ onClick={() => handleAudibleScrubChange(!currentAudibleScrub)}
2228
+ aria-label="Toggle audible drag-scrub"
2229
+ aria-pressed={currentAudibleScrub}
2230
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
2231
+ >
2232
+ {currentAudibleScrub ? <Ear size={12} /> : <EarOff size={12} />}
2233
+ </button>
2234
+ </Tooltip>
2235
+ {/* Default placement. A host that sets onProvideRenderTrigger renders
2236
+ Render in its own chrome instead, so the toolbar button is hidden. */}
2237
+ {!onProvideRenderTrigger && (
2238
+ <button
2239
+ onClick={openRender}
2240
+ className="text-xs px-2.5 py-1 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-colors"
2241
+ >
2242
+ Render →
2243
+ </button>
2244
+ )}
2245
+ </div>
2246
+
2247
+ {/* `data-timeline-scroll`: the canvas timeline measures against this
2248
+ viewport to grow its surface down into the empty space below the
2249
+ tracks (see TimelineCanvas's pane-fill effect). Its height is fixed by
2250
+ the resizable pane, so the measurement never feeds back. */}
2251
+ <div data-timeline-scroll className="flex-1 min-h-0 overflow-y-auto border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
2252
+ <Timeline
2253
+ project={project}
2254
+ clock={clock}
2255
+ onProjectChange={handleProjectChange}
2256
+ onOverlayEdit={commitTimelineEdit}
2257
+ previewAxis={previewAxis}
2258
+ onHoverScrub={handleHoverScrub}
2259
+ selectedIds={selectedIds}
2260
+ onSelectIds={setSelectedIds}
2261
+ // No `onInspectClip` / `onInspectAudio`: the clip-inspect modal they
2262
+ // opened is retired. Single-click selection now populates the right
2263
+ // properties panel, so a double-click on a clip/audio bar is simply a
2264
+ // no-op. The Timeline props still exist (that is its API) — this
2265
+ // editor just has nothing left to do with them.
2266
+ onEditCaption={handleEditCaption}
2267
+ rippleMode={rippleMode}
2268
+ resolveFilePath={resolveFilePath}
2269
+ getWaveformPeaks={getWaveformPeaks}
2270
+ getFilmstrip={getFilmstrip}
2271
+ regenEnabled={regenEnabled}
2272
+ isClipQueued={isClipQueued}
2273
+ renderSubcutRegen={renderSubcutRegen}
2274
+ modalOpen={anyModalOpen}
2275
+ onOpenGoToTime={openGoToTime}
2276
+ actionsRef={timelineActionsRef}
2277
+ mode={timelineMode}
2278
+ onImportFilesToTimeline={onImportFilesToTimeline}
2279
+ pendingDrops={pendingDrops}
2280
+ />
2281
+ </div>
2282
+ </div>
2283
+ )
2284
+
2285
+ // Assets — dedicated separate column to the LEFT of the version rail
2286
+ // (assetsPlacement: 'right'). Classic layouts only.
2287
+ const assetsColumn = assetsPlacement === 'right' && slots?.assetsPanel && (
2288
+ <div className="w-72 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
2289
+ {slots.assetsPanel}
2290
+ </div>
2291
+ )
2292
+
2293
+ // ── Pieces shared by the two layouts' side columns ────────────────────────
2294
+ // The caption editor and the version list live in the CLASSIC right rail and
2295
+ // in the CapCut LEFT panel's Captions / Versions tabs. Built once here, with
2296
+ // one set of props, so the two layouts can never drift apart — only one of
2297
+ // them mounts per render, so sharing the element is free.
2298
+ const captionListPanel = (
2299
+ <CaptionListPanelWithClock
2300
+ captionTrack={project.captions}
2301
+ project={project}
2302
+ selectedIds={selectedIds}
2303
+ onSelectCaption={handleSelectCaption}
2304
+ onCaptionSegmentChange={handleCaptionSegmentChange}
2305
+ onCaptionEdit={(p) => void sync.mutate(() => p as P)}
2306
+ onProjectChange={handleProjectChange}
2307
+ onCaptionSegmentDelete={handleCaptionSegmentDelete}
2308
+ onRegenerateCaptions={handleRegenerateCaptions}
2309
+ // Disables the panel's generate/regenerate trigger while a job is in
2310
+ // flight, from either source: the host's own background job
2311
+ // (`captionsGenerating` prop, set when the host owns the trigger via
2312
+ // `onRegenerateCaptions`) OR'd with this component's internal modal-
2313
+ // open state. The internal half is defensive rather than load-bearing
2314
+ // — CaptionRegenModal is a full-screen blocking portal, so the panel
2315
+ // underneath can't be clicked anyway — but it keeps the panel honest
2316
+ // on its own terms and re-enables on close, so a failed job never
2317
+ // leaves a permanently dead button. The host half is load-bearing:
2318
+ // it's the editor's only signal that an off-component job is running.
2319
+ captionsGenerating={captionsGenerating || regenCaptionsOpen}
2320
+ fps={project.settings?.fps ?? 30}
2321
+ clock={clock}
2322
+ editFocusId={editFocusId}
2323
+ compileOverlay={adapter.compileOverlay}
2324
+ resolveCaptionTemplate={adapter.resolveCaptionTemplate}
2325
+ mode={timelineMode}
2326
+ />
2327
+ )
2328
+ const versionPanel = adapter.listVersionHistory && (
2329
+ <VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} onSaveVersion={handleSaveVersion} saving={saving} onCompareVersion={adapter.versionFrameUrl ? handleCompareVersion : undefined} mode={timelineMode} />
2330
+ )
2331
+
2332
+ /**
2333
+ * The selected overlay's properties, in two tabs: **Content** (its own props
2334
+ * — text, colors, numbers, toggles, images) and **Transform** (the
2335
+ * keyframeable geometry). Content is the default, and it is what the retired
2336
+ * floating "Edit overlay" dialog used to show.
2337
+ *
2338
+ * ONE node, rendered by BOTH layouts — the classic right rail below and the
2339
+ * CapCut `propertiesPanel` further down. That is not incidental tidiness: the
2340
+ * dialog this replaces was mounted at the top level and its Pencil button
2341
+ * lived in the controls bar, so BOTH were layout-independent. Giving the tabs
2342
+ * only to the CapCut column would have left classic hosts (Hub, LP) with a
2343
+ * Transform inspector and no way to reach an overlay's text at all.
2344
+ *
2345
+ * A COMPACT INLINE tab strip, deliberately not `LeftPanelTabs`: that
2346
+ * component's `w-16` vertical icon rail spends a fifth of a 300px column on
2347
+ * chrome. Two buttons in the same underline language as CaptionListPanel's
2348
+ * Style/Captions pair cost a row of height and nothing horizontally. Built
2349
+ * from the shared `TabNav` (`./panels/TabNav`) — the same underline strip
2350
+ * `ClipPropertiesPanel`'s own clip tabs use — rather than a hand-rolled one,
2351
+ * so every small in-panel tab switch in the editor speaks the same visual
2352
+ * and accessible language.
2353
+ *
2354
+ * `aria-pressed` rather than `role="tab"`, for CaptionListPanel's reason (and
2355
+ * `TabNav`'s own doc comment): the CapCut LEFT rail is a real tablist, and a
2356
+ * second set of tabs in the same tree makes `getByRole('tab', …)` ambiguous
2357
+ * in the host's own tests.
2358
+ *
2359
+ * With nothing (or a non-overlay) selected this falls through to
2360
+ * `OverlayInspector`'s own "Select an overlay…" empty state rather than a
2361
+ * bare tab strip over an empty pane — the classic rail renders this
2362
+ * unconditionally and relied on exactly that empty state before.
2363
+ */
2364
+ const overlayPropertiesPanel = selectedOverlayItem ? (
2365
+ <>
2366
+ <TabNav
2367
+ tabs={OVERLAY_PANEL_TABS}
2368
+ value={overlayPanelTab}
2369
+ onChange={setOverlayPanelTab}
2370
+ ariaLabel="Overlay panel view"
2371
+ className="shrink-0 border-b border-[var(--editor-border)]"
2372
+ />
2373
+ {overlayPanelTab === 'content' ? (
2374
+ <OverlayContentPanel
2375
+ item={selectedOverlayItem}
2376
+ onPreview={next => previewOverlayProps(selectedOverlayItem.id, next)}
2377
+ onCommit={commitOverlayEdit}
2378
+ fileUrl={adapter.fileUrl}
2379
+ uploadFile={file => adapter.uploadFile(file, project.id)}
2380
+ mode={timelineMode}
2381
+ />
2382
+ ) : (
2383
+ <OverlayInspector
2384
+ item={selectedOverlayItem}
2385
+ clock={clock}
2386
+ onPreview={previewOverlayInspectorChange}
2387
+ onCommit={commitOverlayInspectorChange}
2388
+ onChange={applyOverlayInspectorChange}
2389
+ onSeek={seekTo}
2390
+ />
2391
+ )}
2392
+ </>
2393
+ ) : (
2394
+ <OverlayInspector
2395
+ item={null}
2396
+ clock={clock}
2397
+ onPreview={previewOverlayInspectorChange}
2398
+ onCommit={commitOverlayInspectorChange}
2399
+ onChange={applyOverlayInspectorChange}
2400
+ onSeek={seekTo}
2401
+ />
2402
+ )
2403
+
2404
+ // Right rail — CLASSIC LAYOUTS ONLY (Hub / LP). The CapCut layout replaces it
2405
+ // with the properties-only `propertiesPanel` below and moves this rail's
2406
+ // captions and versions into the left panel's tabs.
2407
+ // Version history + run history slot, the sidebar caption list,
2408
+ // and (in 'sidebar' placement) the assets panel stacked beneath them in the
2409
+ // SAME column (its own col-resize divider is
2410
+ // included). `!!project.captions || !!handleRegenerateCaptions` is a new
2411
+ // disjunct (SP5-captions Phase 5): CaptionListPanel now lives in this rail
2412
+ // instead of a bottom panel, so the rail must appear whenever THAT panel
2413
+ // has anything to offer — either existing captions, or (on a host that
2414
+ // supports `generateCaptions`) the means to create them from scratch. The
2415
+ // second half matters even with zero captions: the retired bottom
2416
+ // TranscriptPanel showed "Regenerate" whenever the host supported it,
2417
+ // regardless of caption count, and dropping that would make caption
2418
+ // generation unreachable from the editor on a bare project with no other
2419
+ // rail content.
2420
+ // `!!selectedOverlayItem` is a further disjunct (SP9b T3.2): the overlay
2421
+ // inspector below lives in this same rail, so the rail must appear whenever
2422
+ // an overlay is selected — even on a project with no versions, captions, or
2423
+ // assets to otherwise justify the rail's existence.
2424
+ const rightRail = (adapter.listVersionHistory || slots?.runHistory ||
2425
+ (assetsPlacement === 'sidebar' && slots?.assetsPanel) ||
2426
+ !!project.captions || !!handleRegenerateCaptions || !!selectedOverlayItem) && (
2427
+ <>
2428
+ {/* Vertical divider, the same affordance as the preview/timeline one. */}
2429
+ <div
2430
+ role="separator"
2431
+ aria-orientation="vertical"
2432
+ aria-label="Resize sidebar"
2433
+ onMouseDown={startRailDrag}
2434
+ onDoubleClick={() => setRailWidth(DEFAULT_RAIL_PX)}
2435
+ className="group shrink-0 w-[5px] cursor-col-resize bg-transparent"
2436
+ >
2437
+ <div className="w-px h-full bg-[var(--editor-border)] transition-colors group-hover:bg-[var(--editor-accent)]" />
2438
+ </div>
2439
+
2440
+ <div
2441
+ style={{ width: railWidth }}
2442
+ className="shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden"
2443
+ >
2444
+
2445
+ {/* SP9b T3.2 — the selected overlay's properties. Above VersionPanel:
2446
+ it's the thing the operator is actively editing,
2447
+ versions/captions/assets are reference material below it. Was the
2448
+ Transform inspector alone; it is the Content/Transform tab pair now
2449
+ (see `overlayPropertiesPanel`), because the floating dialog that
2450
+ used to carry Content was reachable from this layout too. Still
2451
+ renders its own "Select an overlay…" empty state when nothing, or a
2452
+ non-overlay, is selected. */}
2453
+ {overlayPropertiesPanel}
2454
+
2455
+ {versionPanel}
2456
+
2457
+ {/* Sidebar caption editor, directly below version history. Its own
2458
+ flex-1 wrapper so the list scrolls independently of the rest of
2459
+ the rail — mirrors the assetsPanel wrapper just below. Gated the
2460
+ same way that one is: nothing to show, nothing rendered, rather
2461
+ than an empty bordered box (CaptionListPanel has its own internal
2462
+ guard too, for callers that can't check this ahead of time). */}
2463
+ {(project.captions || handleRegenerateCaptions) && (
2464
+ <div className="flex-1 min-h-0 overflow-hidden border-t border-[var(--editor-border)] flex flex-col">
2465
+ {captionListPanel}
2466
+ </div>
2467
+ )}
2468
+ {/* Host injects the Montaj-flavored "Previous runs" snapshot list here.
2469
+ RunSnapshot / project.history are host-only types — the package never
2470
+ reads them. When absent nothing is rendered. */}
2471
+ {slots?.runHistory}
2472
+ {/* Assets stacked below versions/runs (assetsPlacement: 'sidebar'). The
2473
+ host's panel manages its own scroll; flex-1 lets it take the
2474
+ remaining rail height. A top border separates it from the runs. */}
2475
+ {assetsPlacement === 'sidebar' && slots?.assetsPanel && (
2476
+ <div className="flex-1 min-h-0 overflow-hidden border-t border-[var(--editor-border)] flex flex-col">
2477
+ {slots.assetsPanel}
2478
+ </div>
2479
+ )}
2480
+ </div>
2481
+ </>
2482
+ )
2483
+
2484
+ // Project media / assets — full-width region stacked BELOW the editor
2485
+ // (assetsPlacement: 'bottom'). Classic layouts only.
2486
+ const bottomAssets = assetsPlacement === 'bottom' && slots?.assetsPanel && (
2487
+ <div className="shrink-0 border-t border-[var(--editor-border)] w-full flex flex-col max-h-[45%] overflow-hidden">
2488
+ {slots.assetsPanel}
2489
+ </div>
2490
+ )
2491
+
2492
+ // Left panel (CapCut layout only): the editor's browser column — Media,
2493
+ // Captions and Versions behind a vertical icon rail — in a width-resizable
2494
+ // column, with a col-resize divider on its RIGHT edge. Captions and version
2495
+ // history used to stack into the right rail; in this layout that rail is
2496
+ // properties-only, so they live here now. Each tab is added only when it has
2497
+ // something to show, so the rail never offers a dead icon.
2498
+ const leftPanelTabs: LeftPanelTab[] = []
2499
+ if (slots?.mediaPanel) {
2500
+ leftPanelTabs.push({ id: 'media', label: 'Media', icon: <Film size={16} />, content: slots.mediaPanel })
2501
+ }
2502
+ // Same gate the classic rail uses for its caption section — existing
2503
+ // captions, or (on a host that supports `generateCaptions`) the means to
2504
+ // create them from scratch.
2505
+ if (project.captions || handleRegenerateCaptions) {
2506
+ leftPanelTabs.push({
2507
+ id: 'captions',
2508
+ label: 'Captions',
2509
+ icon: <Captions size={16} />,
2510
+ // The same flex-1 wrapper the rail gives it, minus the rail's top
2511
+ // border: inside a tab panel there is nothing above to divide from.
2512
+ content: <div className="flex-1 min-h-0 overflow-hidden flex flex-col">{captionListPanel}</div>,
2513
+ })
2514
+ }
2515
+ if (adapter.listVersionHistory || slots?.runHistory) {
2516
+ leftPanelTabs.push({
2517
+ id: 'versions',
2518
+ label: 'Versions',
2519
+ icon: <History size={16} />,
2520
+ content: (
2521
+ <>
2522
+ {versionPanel}
2523
+ {/* The host's "Previous runs" list, directly beneath version history —
2524
+ the same adjacency it had in the rail. RunSnapshot / project.history
2525
+ are host-only types; the package never reads them. */}
2526
+ {slots?.runHistory}
2527
+ </>
2528
+ ),
2529
+ })
2530
+ }
2531
+
2532
+ const leftPanel = (
2533
+ <>
2534
+ <div
2535
+ style={{ width: mediaPanelWidth }}
2536
+ className="shrink-0 border-r border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden min-h-0"
2537
+ >
2538
+ <LeftPanelTabs tabs={leftPanelTabs} defaultTabId="captions" activationRequest={{ id: 'captions', nonce: captionTabNonce }} className="flex-1 min-h-0" />
2539
+ </div>
2540
+ {/* Divider on the left panel's RIGHT edge — drag right widens the column.
2541
+ Kept under its original "Resize media panel" name: it is the same
2542
+ affordance on the same persisted width, and renaming it would break
2543
+ every host/test that reaches for it. */}
2544
+ <div
2545
+ role="separator"
2546
+ aria-orientation="vertical"
2547
+ aria-label="Resize media panel"
2548
+ onMouseDown={startMediaPanelDrag}
2549
+ onDoubleClick={() => setMediaPanelWidth(DEFAULT_MEDIA_PANEL_PX)}
2550
+ className="group shrink-0 w-[5px] cursor-col-resize bg-transparent"
2551
+ >
2552
+ <div className="w-px h-full bg-[var(--editor-border)] transition-colors group-hover:bg-[var(--editor-accent)]" />
2553
+ </div>
2554
+ </>
2555
+ )
2556
+
2557
+ // Right properties panel (CapCut layout only) — the contextual inspector for
2558
+ // whatever is selected, in place of the classic stacked rail.
2559
+ //
2560
+ // ALWAYS rendered, never gated on the selection (Sam): a column that came and
2561
+ // went would resize the preview every time the operator clicked from a clip
2562
+ // to empty space, so it holds its width and shows an empty state instead.
2563
+ // Three branches: a selected clip → the tabbed `ClipPropertiesPanel`, whose
2564
+ // Transform tab is the SAME `OverlayInspector` instance the overlay branch
2565
+ // below uses (passed in as `transformSlot`, wired to the identical
2566
+ // preview/commit/change trio) — a clip and an overlay's geometry controls
2567
+ // are one component either way, just reached through a different tab shell;
2568
+ // a selected audio track → the same `ClipPropertiesPanel`, which renders its
2569
+ // untabbed `AudioSection` instead (audio has no Transform/Speed/Crop, so
2570
+ // there is nothing to tab between); a selected overlay → the Content/
2571
+ // Transform pair (`overlayPropertiesPanel`); nothing selected → a centered
2572
+ // empty state, the host's `slots.propertiesEmptyState` when supplied (Montaj
2573
+ // brands it with its logo) or the generic "Select an element" default
2574
+ // otherwise.
2575
+ const propertiesPanel = (
2576
+ <>
2577
+ {/* Vertical divider, the same affordance as the preview/timeline one. */}
2578
+ <div
2579
+ role="separator"
2580
+ aria-orientation="vertical"
2581
+ aria-label="Resize sidebar"
2582
+ onMouseDown={startRailDrag}
2583
+ onDoubleClick={() => setRailWidth(DEFAULT_RAIL_PX)}
2584
+ className="group shrink-0 w-[5px] cursor-col-resize bg-transparent"
2585
+ >
2586
+ <div className="w-px h-full bg-[var(--editor-border)] transition-colors group-hover:bg-[var(--editor-accent)]" />
2587
+ </div>
2588
+
2589
+ <div
2590
+ style={{ width: railWidth }}
2591
+ className="shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden"
2592
+ >
2593
+ {/* Scrolls as one: both panels are stacks of shrink-0 sections, and the
2594
+ audio track's fades/ducking groups (or a tall generationSlot) run
2595
+ past the bottom of a short window. */}
2596
+ <div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
2597
+ {clipSelection ? (
2598
+ <ClipPropertiesPanel
2599
+ selection={clipSelection}
2600
+ onPreviewClip={previewClipChange}
2601
+ onCommitClip={commitClipChange}
2602
+ onChangeClip={applyClipChange}
2603
+ onPreviewAudio={previewAudioChange}
2604
+ onCommitAudio={commitAudioChange}
2605
+ onChangeAudio={applyAudioChange}
2606
+ // Transform tab body: the SAME OverlayInspector instance (and the
2607
+ // same preview/commit/change trio) the overlay branch below uses —
2608
+ // a clip's geometry is edited by the identical control, just
2609
+ // reached through this panel's tab shell instead of the overlay
2610
+ // Content/Transform pair. Undefined for an audio-track selection
2611
+ // (audio has no geometry to transform), which also means
2612
+ // ClipPropertiesPanel offers no Transform tab for it.
2613
+ transformSlot={
2614
+ clipSelection.kind === 'clip' ? (
2615
+ <OverlayInspector
2616
+ item={clipSelection.item}
2617
+ clock={clock}
2618
+ onPreview={previewOverlayInspectorChange}
2619
+ onCommit={commitOverlayInspectorChange}
2620
+ onChange={applyOverlayInspectorChange}
2621
+ onSeek={seekTo}
2622
+ />
2623
+ ) : undefined
2624
+ }
2625
+ // Crop tab: offered only when the selected clip IS the source-crop
2626
+ // target — `cropTarget` (above) already encodes that rule (tracks[0]
2627
+ // video with a src), so this just checks the current clip against
2628
+ // it rather than re-deriving the condition. Unlike the toolbar
2629
+ // button (above), which is an honest toggle, this is a one-way
2630
+ // ENTER: the tab body stays mounted once opened, so a toggle here
2631
+ // would silently exit crop mode on a second click with no
2632
+ // feedback from the button itself (no aria-pressed, no label
2633
+ // change). Exiting stays reachable through the toolbar button and
2634
+ // the crop modal's own close path.
2635
+ onOpenCrop={
2636
+ clipSelection.kind === 'clip' && cropTarget?.id === clipSelection.item.id
2637
+ ? () => setCropMode(true)
2638
+ : undefined
2639
+ }
2640
+ // The host's generation panel is per-CLIP — it draws that clip's
2641
+ // prompt/model/refImages — so the editor, which owns the
2642
+ // selection, resolves the node here rather than taking a static
2643
+ // one the host would have to track selection to build. Gated on
2644
+ // the clip actually being a regenerable generation (project has
2645
+ // regen enabled AND the clip still carries its frozen generation
2646
+ // provenance), not merely on being a video: `ClipTabs` shows a
2647
+ // Generate tab whenever this slot is defined, so a bare
2648
+ // video-type check would put a dead, empty tab on every ordinary
2649
+ // video clip in every non-ai_video project.
2650
+ // KEYED ON THE CLIP ID, and that key is load-bearing. The host's
2651
+ // panel seeds its regen form (prompt, model, duration, ref
2652
+ // images) from the clip in `useState` initializers, which run
2653
+ // ONCE per mount. Selecting a different video clip keeps
2654
+ // `clipSelection.kind === 'clip'`, so without a key React
2655
+ // reconciles this subtree IN PLACE and the form keeps the
2656
+ // PREVIOUS clip's content while `clipId` points at the new one —
2657
+ // queueing a regeneration that spends credits rendering the
2658
+ // wrong clip's prompt. The retired modal never hit this because
2659
+ // it remounted on every double-click. Keying here rather than in
2660
+ // the host means every host gets that guarantee from the seam
2661
+ // itself instead of having to know about it.
2662
+ generationSlot={
2663
+ clipSelection.kind === 'clip'
2664
+ && clipSelection.item.type === 'video'
2665
+ && regenEnabled
2666
+ && clipSelection.item.generation
2667
+ && renderGenerationPanel
2668
+ ? <Fragment key={clipSelection.item.id}>{renderGenerationPanel({ clipId: clipSelection.item.id })}</Fragment>
2669
+ : undefined
2670
+ }
2671
+ mode={timelineMode}
762
2672
  />
763
- )}
764
- {/* Default placement. A host that sets onProvideRenderTrigger renders
765
- Render in its own chrome instead, so the toolbar button is hidden. */}
766
- {!onProvideRenderTrigger && (
767
- <button
768
- onClick={openRender}
769
- className="text-xs px-2.5 py-1 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-colors"
770
- >
771
- Render →
772
- </button>
2673
+ ) : selectedOverlayItem ? (
2674
+ /* The selected overlay's Content/Transform tabs — the SAME node the
2675
+ classic right rail renders (see `overlayPropertiesPanel`), so the
2676
+ two layouts can never drift apart on which tab is showing or what
2677
+ either one edits. */
2678
+ overlayPropertiesPanel
2679
+ ) : (
2680
+ /* Nothing selected: the host's branded empty state, or the
2681
+ package's generic centered default. */
2682
+ slots?.propertiesEmptyState ?? (
2683
+ <div className="flex-1 flex flex-col items-center justify-center gap-2.5 px-6 text-center">
2684
+ <SquareDashedMousePointer size={26} className="text-[var(--editor-text)]/25" />
2685
+ <p className="text-xs font-medium text-[var(--editor-text)]/60">Select an element</p>
2686
+ <p className="text-[11px] text-[var(--editor-text)]/40">Choose a clip, overlay, or track to edit its properties.</p>
2687
+ </div>
2688
+ )
773
2689
  )}
774
2690
  </div>
2691
+ </div>
2692
+ </>
2693
+ )
775
2694
 
776
- <div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
777
- <Timeline
778
- project={project}
779
- clock={clock}
780
- onProjectChange={handleProjectChange}
781
- onCaptionEdit={(p) => void sync.mutate(() => p as P)}
782
- onOverlayEdit={(p) => void sync.mutate(() => p as P)}
783
- onEditOverlay={requestEditOverlay}
784
- selectedIds={selectedIds}
785
- onSelectIds={setSelectedIds}
786
- selectedCaptionId={selectedCaptionId}
787
- onSelectCaption={handleSelectCaption}
788
- onCaptionSegmentChange={handleCaptionSegmentChange}
789
- onSplit={handleSplit}
790
- onCut={handleCut}
791
- onInspectClip={(id) => setInspecting({ kind: 'clip', id })}
792
- onInspectAudio={(id) => setInspecting({ kind: 'audio', id })}
793
- onSaveProject={(p) => sync.mutate(() => p as P)}
794
- rippleMode={rippleMode}
795
- getWaveformChunks={getWaveformChunks}
796
- resolveFilePath={resolveFilePath}
797
- regenEnabled={regenEnabled}
798
- isClipQueued={isClipQueued}
799
- renderSubcutRegen={renderSubcutRegen}
800
- onRegenerateCaptions={adapter.generateCaptions ? () => setRegenCaptionsOpen(true) : undefined}
801
- />
2695
+ return (
2696
+ <div className="flex flex-col flex-1 overflow-hidden">
2697
+ {slots?.mediaPanel ? (
2698
+ /* CapCut layout (opt-in via slots.mediaPanel): three columns across the
2699
+ top — [left panel | preview | properties] — with a full-width timeline
2700
+ strip below. `splitRef` wraps the WHOLE top-row + timeline region so the
2701
+ row divider trades height between them (MIN_PREVIEW_PANE_PX now guards
2702
+ the entire top row); `workAreaRef` is the top row, so the rail/media
2703
+ width drags still measure against the space those three columns share.
2704
+ `previewRegion` / `timelinePane` / `rowDivider` are the exact same nodes
2705
+ the classic layout renders. The two side columns are NOT shared: this
2706
+ layout browses (media/captions/versions) on the left and inspects the
2707
+ selection on the right, where the classic layout stacks all of it into
2708
+ one right rail. */
2709
+ <div ref={splitRef} className="flex flex-col flex-1 overflow-hidden min-h-0">
2710
+ <div ref={workAreaRef} className="flex flex-1 overflow-hidden min-h-0">
2711
+ {leftPanel}
2712
+ {previewRegion}
2713
+ {propertiesPanel}
2714
+ </div>
2715
+ {rowDivider}
2716
+ {timelinePane}
802
2717
  </div>
2718
+ ) : (
2719
+ /* Classic layout — byte-for-byte the pre-media-panel editor (Hub / LP). */
2720
+ <>
2721
+ {/* Work area — editor body + version rail, side by side */}
2722
+ <div ref={workAreaRef} className="flex flex-1 overflow-hidden min-h-0">
2723
+ {/* Main: preview + timeline, split by a draggable divider. The preview
2724
+ takes whatever the timeline pane does not — so dragging the divider up
2725
+ trades preview area for timeline area and vice versa. */}
2726
+ <div ref={splitRef} className="flex flex-col flex-1 overflow-hidden">
2727
+ {previewRegion}
2728
+
2729
+ {rowDivider}
2730
+
2731
+ {timelinePane}
803
2732
  </div>
804
2733
 
805
2734
  {/* Assets — dedicated separate column to the LEFT of the version rail
806
2735
  (assetsPlacement: 'right', two distinct columns). The Montaj-local OS
807
2736
  layout uses 'sidebar' instead (stacked into the version rail below).
808
2737
  The host's panel manages its own scroll. */}
809
- {assetsPlacement === 'right' && slots?.assetsPanel && (
810
- <div className="w-72 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
811
- {slots.assetsPanel}
812
- </div>
813
- )}
2738
+ {assetsColumn}
814
2739
 
815
2740
  {/* Right rail — version history + run history slot, and (in 'sidebar'
816
2741
  placement) the assets panel stacked beneath them in the SAME column.
817
2742
  This is the historical Montaj-local OS layout: versions on top, assets
818
2743
  right below, one column — not a separate assets column. */}
819
- {(adapter.listVersionHistory || slots?.runHistory ||
820
- (assetsPlacement === 'sidebar' && slots?.assetsPanel)) && (
821
- <div className={`${assetsPlacement === 'sidebar' ? 'w-56' : 'w-48'} shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden`}>
822
- {adapter.listVersionHistory && (
823
- <VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
824
- )}
825
- {/* Host injects the Montaj-flavored "Previous runs" snapshot list here.
826
- RunSnapshot / project.history are host-only types — the package never
827
- reads them. When absent nothing is rendered. */}
828
- {slots?.runHistory}
829
- {/* Assets stacked below versions/runs (assetsPlacement: 'sidebar'). The
830
- host's panel manages its own scroll; flex-1 lets it take the
831
- remaining rail height. A top border separates it from the runs. */}
832
- {assetsPlacement === 'sidebar' && slots?.assetsPanel && (
833
- <div className="flex-1 min-h-0 overflow-hidden border-t border-[var(--editor-border)] flex flex-col">
834
- {slots.assetsPanel}
835
- </div>
836
- )}
837
- </div>
838
- )}
2744
+ {rightRail}
839
2745
  </div>
840
2746
 
841
2747
  {/* Project media / assets — full-width region stacked BELOW the editor
842
2748
  (assetsPlacement: 'bottom'). Preferred by width-constrained hosts (Hub).
843
2749
  The host's panel manages its own scroll. */}
844
- {assetsPlacement === 'bottom' && slots?.assetsPanel && (
845
- <div className="shrink-0 border-t border-[var(--editor-border)] w-full flex flex-col max-h-[45%] overflow-hidden">
846
- {slots.assetsPanel}
847
- </div>
2750
+ {bottomAssets}
2751
+ </>
848
2752
  )}
849
2753
 
850
2754
  {/* Source-crop modal — drag-to-pan, aspect presets, zoom. Commits
@@ -881,6 +2785,18 @@ function ReviewSurface<P extends Project>({
881
2785
  />
882
2786
  )}
883
2787
 
2788
+ {/* Command palette — Cmd/Ctrl+K, or the scrubber's time-readout click
2789
+ (opens straight into "go to time"). */}
2790
+ {paletteOpen && (
2791
+ <CommandPalette
2792
+ commands={paletteCommands}
2793
+ initialMode={paletteOpen === 'goto' ? 'goto' : 'list'}
2794
+ onGoToTime={(seconds) => clock.set(Math.max(0, Math.min(getTotalDuration(), seconds)))}
2795
+ onClose={closePalette}
2796
+ themeMode={timelineMode}
2797
+ />
2798
+ )}
2799
+
884
2800
  {/* Render modal — adapter.render stream + host export controls */}
885
2801
  {renderOpen && (
886
2802
  <RenderModal
@@ -888,8 +2804,30 @@ function ReviewSurface<P extends Project>({
888
2804
  adapter={adapter}
889
2805
  exportActions={slots?.exportActions}
890
2806
  progressView={renderProgressView}
2807
+ preRenderOptions={preRenderOptions}
891
2808
  onClose={() => setRenderOpen(false)}
892
2809
  onCancel={() => setRenderOpen(false)}
2810
+ onRenderComplete={() => { void refreshVersions() }}
2811
+ mode={timelineMode}
2812
+ />
2813
+ )}
2814
+
2815
+ {/* Visual A/B version compare — opened from a VersionPanel entry's
2816
+ Compare button. Gated on the adapter capability so a host without
2817
+ `versionFrameUrl` never sees an unusable Compare affordance's modal.
2818
+ `durationSeconds` reuses `preRenderOptions.durationSec` — the same
2819
+ "video spine's last frame" figure RenderModal's export dialog
2820
+ already computes, so the scrub slider covers the real project
2821
+ length without a second duration pass. */}
2822
+ {compareOpen != null && adapter.versionFrameUrl && (
2823
+ <VersionCompare
2824
+ projectId={project.id}
2825
+ versions={listVersions(versions)}
2826
+ initialLeftHash={compareOpen}
2827
+ frameUrl={adapter.versionFrameUrl}
2828
+ durationSeconds={preRenderOptions.durationSec}
2829
+ onClose={() => setCompareOpen(null)}
2830
+ mode={timelineMode}
893
2831
  />
894
2832
  )}
895
2833
 
@@ -898,38 +2836,51 @@ function ReviewSurface<P extends Project>({
898
2836
  montaj persists the regenerated captions server-side and the SSE frame
899
2837
  reconciles, so a saveProject here would double-write. applyExternal keeps
900
2838
  it out of the undo stack (server-authored, not a user edit). */}
901
- {regenCaptionsOpen && adapter.generateCaptions && (
2839
+ {regenCaptionsOpen && adapter.generateCaptions && !onRegenerateCaptions && (
902
2840
  <CaptionRegenModal
903
2841
  adapter={adapter}
904
2842
  projectId={project.id}
2843
+ existingRowCount={maxCaptionLane(project.captions?.segments ?? []) + 1}
905
2844
  onClose={() => setRegenCaptionsOpen(false)}
906
2845
  onDone={(captions) => {
907
2846
  sync.applyExternal({ ...syncProjectRef.current, captions } as P)
908
2847
  setRegenCaptionsOpen(false)
909
2848
  }}
2849
+ mode={timelineMode}
910
2850
  />
911
2851
  )}
912
2852
 
913
- {/* Overlay props dialog — edits the selected overlay's primitive props
914
- (text, colors, numbers, toggles). Opened from the preview double-click,
915
- the controls bar, or a timeline block. Edits preview live (transient) and
916
- undo as one step on Save. */}
917
- {editingOverlayItem && (
918
- <OverlayPropsModal
919
- itemProps={editingOverlayItem.props ?? {}}
920
- fileUrl={adapter.fileUrl}
921
- uploadFile={(file) => adapter.uploadFile(file, project.id)}
922
- onPreview={(next) => previewOverlayProps(editingOverlayItem.id, next)}
923
- onSave={() => commitOverlayEdit()}
924
- onClose={cancelOverlayEdit}
2853
+ {/* Audio polish modal — owns its own transient/commit/discard cycle via
2854
+ `sync`'s three primitives, passed individually (not as a `sync` object:
2855
+ that's the modal's own prop contract). `project={sync.project}` sources
2856
+ the modal's baseline snapshot AND its watch for an external (SSE) frame
2857
+ landing over the preview mid-review; it cannot be re-derived from a prop
2858
+ that changes as drafts are pushed. */}
2859
+ {polishOpen && adapter.analyzeAudioPolish && (
2860
+ <AudioPolishModal
2861
+ projectId={project.id}
2862
+ adapter={adapter}
2863
+ project={sync.project}
2864
+ selectionIds={selectedIds}
2865
+ mutateTransient={sync.mutateTransient}
2866
+ commit={sync.commit}
2867
+ discardTransient={sync.discardTransient}
2868
+ onClose={() => setPolishOpen(false)}
2869
+ mode={timelineMode}
925
2870
  />
926
2871
  )}
927
2872
 
928
- {/* Clip / audio inspector — host-rendered via render-prop seam. */}
929
- {inspecting && renderClipInspector?.({
930
- item: inspecting,
931
- onClose: () => setInspecting(null),
932
- })}
933
2873
  </div>
934
2874
  )
935
2875
  }
2876
+
2877
+ // CaptionListPanel displays the active-segment highlight, so it genuinely
2878
+ // needs to re-render every tick. Subscribing HERE (rather than inside
2879
+ // ReviewSurface) keeps that per-tick re-render scoped to this leaf instead of
2880
+ // the whole review surface (toolbar + timeline + every context consumer) —
2881
+ // the same reasoning, and the same shape, as Timeline.tsx's
2882
+ // `TranscriptPanelWithClock` for the now-retired bottom transcript panel.
2883
+ function CaptionListPanelWithClock({ clock, ...rest }: Omit<CaptionListPanelProps, 'currentTime'>) {
2884
+ const currentTime = usePlaybackTime(clock)
2885
+ return <CaptionListPanel currentTime={currentTime} clock={clock} {...rest} />
2886
+ }