@bycrux/editor 0.11.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -1,51 +1,92 @@
1
- import { useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
2
- import AudioTrackRow from './AudioTrackRow'
3
- import type { GetWaveformChunks, ResolveFilePath } from './AudioWaveformLayer'
4
- import type { Project } from '../../types'
5
- import { collapseGaps } from '../cuts'
6
- import { ratioFromClientX } from './utils'
1
+ import { useEffect, useMemo, useRef, useState, type MutableRefObject, type ReactNode } from 'react'
2
+ import { Scissors } from 'lucide-react'
3
+ import { EASING_NAMES } from '@bycrux/timeline-core'
4
+ import type { FilmstripIndex, GetFilmstripArgs, GetWaveformPeaksArgs, PeaksData, PendingDrop, Project, ResolveFilePath, TimelineDropPlacement } from '../../types'
5
+ import type { EasingName, KeyframeProp, VisualItem } from '../../schema'
6
+ import { reflowMagneticLanes } from '../audioMagnet'
7
+ import { normalizeCaptionLanes } from '../captionLanes'
8
+ import { collapseGaps, setClipSpeed } from '../cuts'
9
+ import { removeKeyframesAt, setKeyframeEasing } from '../keyframeOps'
7
10
  import { useTimelineZoom } from './useTimelineZoom'
8
11
  import { TimelineContext, type TimelineContextValue } from './TimelineContext'
9
- import { usePlaybackTime, type PlaybackClock } from '../playback-clock'
12
+ import type { PlaybackClock } from '../playback-clock'
10
13
  import Scrubber from './Scrubber'
11
- import TranscriptPanel from './TranscriptPanel'
12
- import TranscriptModal from './TranscriptModal'
13
- import VisualTrackRow from './VisualTrackRow'
14
- import CaptionTrackRow from './CaptionTrackRow'
14
+ import TrackGutter from './TrackGutter'
15
+ import { computeTimelineLayout, timelinePalette, type TimelineLayout, type TimelineMode } from './canvas/draw'
16
+ import { DEFAULT_FADE_CURVE, fadeCurveIconPoints, type FadeCurve } from './canvas/fade-curve'
17
+ import { VISUAL_EDGE_TOLERANCE_PX } from './canvas/hit-test'
18
+ import { keyframeUnionTimes } from './canvas/keyframe-strip'
19
+ import { mapTrackItems, normalizeTracks, updateAudioTrack } from './timeline-model'
15
20
  import { deleteSelection, toggleSelection } from './multiSelectOps'
16
- import type { CaptionEditPatch } from './makeCaptionEdit'
21
+ import { computeAutoCrossfade, computeDerivedTiming, trackItems } from './timeline-model'
22
+ import TimelineCanvas, { useCanvasZoomControls, type ZoomControls } from './canvas/TimelineCanvas'
23
+ import type { KeyframeSelection } from './canvas/pointer-machine'
24
+ import { timeToX, useViewportStore, useViewportValue, xToTime, type ViewportStore } from './canvas/viewport'
25
+ import { useKeymap, matchesArrowLeft, matchesArrowRight, matchesDelete, matchesModKey } from '../keymap'
26
+ import { Tooltip } from '../../ui/Tooltip'
27
+
28
+ /** Re-exported so the host (`VideoEditor`) can name the mode it resolves from
29
+ * the theme without reaching into `canvas/draw` — Timeline is the seam it
30
+ * already imports. */
31
+ export type { TimelineMode } from './canvas/draw'
32
+
33
+ /** How long the auto-crossfade pass waits after an audio-timing change before
34
+ * it COMMITS the derived fades. Long enough that the continuous changes of a
35
+ * live drag keep resetting it (so a whole gesture commits its crossfade once,
36
+ * via `commitTimelineEdit`, not per frame); short enough that a settled edge
37
+ * edit or ripple-delete lands its fade promptly. See the effect below. */
38
+ export const CROSSFADE_COMMIT_DELAY_MS = 250
39
+
40
+ /** Imperative actions a host-level command palette can trigger on the
41
+ * timeline's zoom without lifting Timeline's local state up. Filled via
42
+ * `actionsRef` (SP5 T9) — mirrors `transportRef`'s "ref threaded down,
43
+ * written by the owner" shape used for PreviewPlayer. */
44
+ export interface TimelineActions {
45
+ zoomFit: () => void
46
+ }
17
47
 
18
48
  interface TimelineProps {
19
49
  project: Project
20
50
  clock: PlaybackClock
21
51
  onProjectChange?: (p: Project) => void
22
- onCaptionEdit?: (p: Project) => void
23
52
  onOverlayEdit?: (p: Project) => void
24
- /** Open the overlay props dialog (owned by VideoEditor). Threaded to each
25
- * VisualTrackRow so a selected overlay block can offer an edit button. */
26
- onEditOverlay?: (id: string) => void
27
- /** Unified selection — covers both visual items and audio tracks. */
53
+ /** Unified selection — covers both visual items and audio tracks. Captions
54
+ * share this same array (D1) — a caption id is selected/moved/trimmed on
55
+ * the canvas timeline exactly like a clip or audio track. Caption editing
56
+ * UI itself (text, style, per-segment color) lives in the sidebar
57
+ * CaptionListPanel now, not in Timeline — see VideoEditor.tsx. */
28
58
  selectedIds?: string[]
29
59
  onSelectIds?: (ids: string[]) => void
30
- /** Selected caption segment id — shared with the preview's selection box.
31
- * Mutually exclusive with `selectedIds` (see CaptionTrackRow). */
32
- selectedCaptionId?: string | null
33
- onSelectCaption?: (id: string | null) => void
34
- /** Commit a caption segment patch — text edit or edge-drag retime. Threaded
35
- * straight to CaptionTrackRow (see makeCaptionEdit.ts). */
36
- onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
37
- onSplit?: (at: number) => void
38
- onCut?: (cut: { start: number; end: number }) => void
39
60
  onInspectClip?: (id: string) => void
40
61
  onInspectAudio?: (id: string) => void
41
- onSaveProject?: (p: Project) => Promise<unknown>
62
+ /** Double-click on a caption block. Forwarded straight to
63
+ * `TimelineCanvas` — Timeline itself does nothing with it beyond passing it
64
+ * through, same as `onInspectClip`/`onInspectAudio` above. The host
65
+ * (VideoEditor) uses this to focus the segment's row in the sidebar
66
+ * CaptionListPanel, since a caption has no inspector dialog of its own. */
67
+ onEditCaption?: (id: string) => void
42
68
  rippleMode?: boolean
43
- /** Audio-waveform fetcher, threaded to every AudioWaveformLayer. In V4 the
44
- * VideoEditor wires this from `adapter.getWaveformChunks`. Absent → no
45
- * waveforms render (graceful). */
46
- getWaveformChunks?: GetWaveformChunks
69
+ /**
70
+ * CapCut's "preview axis" toggle, owned by VideoEditor's track-controls bar
71
+ * and OFF by default — off is exactly the behaviour this timeline has always
72
+ * had, so omitting it changes nothing. On, a yellow cursor line tracks the
73
+ * pointer and `onHoverScrub` reports the time under it.
74
+ */
75
+ previewAxis?: boolean
76
+ /** The time under the pointer while the axis is on, null when it leaves.
77
+ * Fires per mousemove — VideoEditor routes it into an external store. */
78
+ onHoverScrub?: (time: number | null) => void
47
79
  /** Resolves a waveform chunk's host path into a displayable URL. */
48
80
  resolveFilePath?: ResolveFilePath
81
+ /** Zoom-bucketed peaks fetcher for the canvas timeline's waveforms (T6),
82
+ * threaded from `adapter.getWaveformPeaks`. Passed straight to
83
+ * `TimelineCanvas`. Absent → no waveforms anywhere (graceful). */
84
+ getWaveformPeaks?: (args: GetWaveformPeaksArgs) => Promise<PeaksData>
85
+ /** Filmstrip-index fetcher for the canvas timeline's tile strips + hover-
86
+ * scrub thumb (T7), threaded from `adapter.getFilmstrip`. Passed straight
87
+ * to `TimelineCanvas`. Absent → no filmstrips or thumbs anywhere
88
+ * (graceful). */
89
+ getFilmstrip?: (args: GetFilmstripArgs) => Promise<FilmstripIndex>
49
90
  /** Host-computed gate for the per-clip subcut-regenerate affordance (Montaj:
50
91
  * ai_video projects). The package never reads `projectType`. */
51
92
  regenEnabled?: boolean
@@ -59,328 +100,937 @@ interface TimelineProps {
59
100
  * regenQueue, storyboard, and onSave — none of which the package types know.
60
101
  * Absent → the subcut tool is simply not rendered. */
61
102
  renderSubcutRegen?: (ctx: { clipId: string; onClose: () => void }) => ReactNode
62
- /** Opens the caption-regeneration modal. Threaded down to TranscriptPanel.
63
- * Provided only when the host adapter supports `generateCaptions`; absent →
64
- * the "Regenerate" button is hidden. */
65
- onRegenerateCaptions?: () => void
103
+ /**
104
+ * SP5 T9 — true while a HOST-level dialog (RenderModal, the command
105
+ * palette, etc.) is open, so Timeline's own keymap (arrows/delete/enter/
106
+ * escape) doesn't fire underneath it. Absent (PendingSurface today has no
107
+ * such dialogs) → the keymap's own guards apply, unchanged.
108
+ */
109
+ modalOpen?: boolean
110
+ /** Opens the command palette's "go to time" input directly. Wired to the
111
+ * scrubber's time readout; absent → the readout is plain text. */
112
+ onOpenGoToTime?: () => void
113
+ /** Imperative zoom actions for a host-level command palette. */
114
+ actionsRef?: MutableRefObject<TimelineActions | null>
115
+ /**
116
+ * Which ground the timeline paints on, resolved from the host theme by
117
+ * `VideoEditor` (`isLightTheme`). Threaded — not looked up here — so the
118
+ * canvas surface, the track rail and the fade-shape icons can never disagree
119
+ * about the mode: three independent lookups is three chances to drift.
120
+ * Defaults to `'dark'`, the only mode this timeline had.
121
+ */
122
+ mode?: TimelineMode
123
+ /** A drop of real OS files onto the canvas surface, threaded straight to
124
+ * `TimelineCanvas` — Timeline does nothing with it beyond passing it
125
+ * through, same as `onInspectClip`/`getFilmstrip` above. Absent → an
126
+ * OS-file drag is not accepted at all and the browser keeps its default
127
+ * handling (see the prop's own doc in types.ts). */
128
+ onImportFilesToTimeline?: (files: File[], placement: TimelineDropPlacement) => void
129
+ /** Ghost bands for the host's in-flight file imports. Passed straight to
130
+ * `TimelineCanvas`. Absent/empty → nothing extra is drawn. */
131
+ pendingDrops?: readonly PendingDrop[]
132
+ }
133
+
134
+ /** Icon size for the fade-shape picker's buttons — small enough for a
135
+ * compact popover, large enough that `exp`/`log`'s curvature reads clearly
136
+ * at a glance. */
137
+ const FADE_CURVE_ICON_SIZE = { width: 40, height: 28 }
138
+ /** Inset between the icon's frame and where the curve itself is drawn, so
139
+ * the polyline never touches (and gets clipped by) the frame's own stroke. */
140
+ const FADE_CURVE_ICON_PAD = 4
141
+
142
+ /**
143
+ * One fade-shape picker option's icon: a small inline SVG tracing the ACTUAL
144
+ * curve (`fadeCurveIconPoints`, sampling the same `fadeGain` the real
145
+ * envelope and waveform scaling use), not a decorative stand-in — "Linear"
146
+ * really is a straight ramp, "Logarithmic" really is concave-down (rises
147
+ * fast, levels near the top), "Exponential" really is concave-up (stays low,
148
+ * rises sharply near the end). Silent is the bottom-left corner, full volume
149
+ * the top-right, matching Vegas' own fade-icon convention.
150
+ *
151
+ * `active` brightens both the frame and the stroke — the same "clearer when
152
+ * it matters" language the timeline's other affordances (trim handles, fade
153
+ * grips) already use — so the picker itself shows which shape is currently
154
+ * set the moment it opens.
155
+ */
156
+ function FadeCurveIcon({ curve, active, mode = 'dark' }: { curve: FadeCurve; active: boolean; mode?: TimelineMode }) {
157
+ const { width: w, height: h } = FADE_CURVE_ICON_SIZE
158
+ const points = fadeCurveIconPoints(curve, w - FADE_CURVE_ICON_PAD * 2, h - FADE_CURVE_ICON_PAD * 2)
159
+ const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
160
+ // The ACTIVE stroke is the real envelope line, taken from the same palette
161
+ // the canvas paints the real fade with — the icon and the thing it sets have
162
+ // to be the same colour or the picker stops being a preview. The inactive
163
+ // pair is this icon's own: white-on-dark in dark mode, near-black-on-light
164
+ // in light, matching the menu ground below (which flips with `mode` too).
165
+ const colors = timelinePalette(mode).colors
166
+ const idleFrame = mode === 'light' ? 'rgba(15,23,42,0.18)' : 'rgba(255,255,255,0.15)'
167
+ const idleCurve = mode === 'light' ? 'rgba(15,23,42,0.7)' : 'rgba(255,255,255,0.75)'
168
+ return (
169
+ <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} aria-hidden="true" className="block">
170
+ <rect
171
+ x={0.5} y={0.5} width={w - 1} height={h - 1} rx={3}
172
+ fill="none"
173
+ stroke={active ? colors.fadeEnvelopeLine : idleFrame}
174
+ strokeWidth={1}
175
+ />
176
+ <path
177
+ d={d}
178
+ transform={`translate(${FADE_CURVE_ICON_PAD},${FADE_CURVE_ICON_PAD})`}
179
+ fill="none"
180
+ stroke={active ? colors.fadeEnvelopeLine : idleCurve}
181
+ strokeWidth={1.75}
182
+ strokeLinecap="round"
183
+ strokeLinejoin="round"
184
+ />
185
+ </svg>
186
+ )
66
187
  }
67
188
 
189
+ /** Gap between a clip's right edge and the right edge of its HTML chrome, in
190
+ * CSS px. Derived from the trim handle's own grab width rather than picked, so
191
+ * the Scissors button can never cover the band a trim has to be started in —
192
+ * a button sitting on the last two pixels of the handle is a trim you can't
193
+ * begin, which is exactly the kind of overlap a magic number drifts into. */
194
+ export const CLIP_CHROME_RIGHT_PAD_PX = VISUAL_EDGE_TOLERANCE_PX + 2
195
+
196
+ /**
197
+ * Per-clip HTML chrome pinned over the canvas timeline: the subcut-regenerate
198
+ * Scissors button and the "queued" badge.
199
+ *
200
+ * ── Why HTML and not paint ───────────────────────────────────────────────
201
+ * These two affordances lived on the DOM clip rows (`VisualTrackRow`). The
202
+ * canvas draws clips as pixels, so there is no per-clip element to hang a
203
+ * button off — and a real button is what a click target with a title, an
204
+ * accessible name and a hover state wants to be. Adding a per-clip button
205
+ * sub-zone to `hit-test.ts` (plus the tap-vs-drag disambiguation that implies)
206
+ * is far more machinery than two pieces of chrome need, so instead each one is
207
+ * an absolutely-positioned HTML node placed by the SAME layout + viewport math
208
+ * the painter uses: `row.y`/`row.height` from `computeTimelineLayout`, and
209
+ * `timeToX` for the item's span.
210
+ *
211
+ * ── Why it is a SIBLING of the canvas surface, not a child ───────────────
212
+ * `TimelineCanvas` binds `mousedown` on its own container element, and a
213
+ * mousedown there STARTS A GESTURE (scrub, drag or trim). A button nested
214
+ * inside that container would therefore start a gesture underneath its own
215
+ * click — and `onMouseDown={e => e.stopPropagation()}` cannot prevent it,
216
+ * because React delegates synthetic events to its ROOT container: the
217
+ * canvas' own native listener sits between the button and that root, so it
218
+ * fires first and a synthetic handler is already too late. Stopping it would
219
+ * mean an imperative native listener per button.
220
+ *
221
+ * Rendering the chrome OUTSIDE `[data-timeline-canvas]` removes the hazard by
222
+ * construction — the canvas' listener is never on the propagation path at
223
+ * all — and costs nothing in alignment: the chrome's positioning context is
224
+ * the flex column that wraps the canvas and nothing else, whose content box is
225
+ * exactly the surface's box (one `w-full` child with an explicit height). The
226
+ * remaining bubble path is Timeline's own container click, which already
227
+ * excludes `button` from its background-click handling.
228
+ *
229
+ * `position: absolute` (not the `fixed` + clientX/clientY snapshot the fade and
230
+ * keyframe menus use) because this chrome is persistent: it has to stay pinned
231
+ * to a clip that pans and rescales, not sit where a one-shot right-click
232
+ * happened.
233
+ */
234
+ function CanvasClipChrome({
235
+ layout,
236
+ viewportStore,
237
+ selectedIds,
238
+ regenEnabled,
239
+ isClipQueued,
240
+ subcutClipId,
241
+ setSubcutClipId,
242
+ }: {
243
+ layout: TimelineLayout
244
+ viewportStore: ViewportStore
245
+ selectedIds: string[]
246
+ regenEnabled?: boolean
247
+ isClipQueued?: (itemId: string) => boolean
248
+ subcutClipId: string | null
249
+ setSubcutClipId: (id: string | null) => void
250
+ }) {
251
+ // Subscribed HERE, not lifted to Timeline's top level — see the "Why HTML
252
+ // and not paint" note above this component. This chrome is the only thing
253
+ // that needs a re-render when the viewport pans or zooms (it has to stay
254
+ // pinned to a clip that just moved under it); isolating the subscription to
255
+ // this component means a wheel-zoom frame re-renders ONLY this chrome, not
256
+ // Timeline's whole subtree (`TimelineCanvas`, `TrackGutter`, `Scrubber`,
257
+ // etc). Same isolation `CanvasZoomBadge` already uses in TimelineCanvas.tsx,
258
+ // for the same reason.
259
+ const viewport = useViewportValue(viewportStore)
68
260
 
69
- export default function Timeline({ project, clock, onProjectChange, onCaptionEdit, onOverlayEdit, onEditOverlay, selectedIds = [], onSelectIds, selectedCaptionId = null, onSelectCaption, onCaptionSegmentChange, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen, onRegenerateCaptions }: TimelineProps) {
70
- const primarySelectedId = selectedIds[0] ?? null
261
+ // Before the first layout pass there is no scale, so every clip would
262
+ // collapse onto x=0 and the chrome would stack up in the corner.
263
+ if (viewport.pxPerSecond <= 0) return null
264
+
265
+ return (
266
+ <>
267
+ {layout.rows.flatMap(row => row.items.flatMap(item => {
268
+ const isSel = selectedIds.includes(item.id)
269
+ const queued = isClipQueued?.(item.id) === true
270
+ // The gate, carried over verbatim from `VisualTrackRow`: the clip is
271
+ // selected, the host has enabled regeneration for this project, the
272
+ // clip still carries its frozen generation provenance, and it is long
273
+ // enough to be worth cutting into sub-shots. Queued is a BADGE, not a
274
+ // disable — a queued clip can be re-opened and re-submitted.
275
+ const showSubcut = isSel && regenEnabled && item.generation && (item.end - item.start) >= 3
276
+ if (!queued && !showSubcut) return []
277
+
278
+ const x0 = timeToX(item.start, viewport)
279
+ const x1 = timeToX(item.end, viewport)
280
+ if (x1 <= 0 || x0 >= viewport.widthPx) return [] // scrolled off-surface
281
+ // Right-anchored: the painter writes each clip's label along its LEFT
282
+ // edge, so the right end is the free space. Clamped so a clip whose
283
+ // end is off-surface keeps its chrome at the surface edge rather than
284
+ // parking it out of view.
285
+ const right = Math.max(CLIP_CHROME_RIGHT_PAD_PX, viewport.widthPx - x1 + CLIP_CHROME_RIGHT_PAD_PX)
286
+ // A LEFT bound too, so the box is the clip's own span and
287
+ // `overflow-hidden` can clip to it. The DOM clip rows got this for
288
+ // free: their chrome sat inside a row element that was already
289
+ // `overflow-hidden`. The canvas has no per-clip element to inherit
290
+ // that from, so right-anchored chrome on a very narrow clip used to
291
+ // overhang LEFTWARD past the clip's start and read as the
292
+ // neighbouring clip's badge. Clamped at 0 the same way `right` is, so
293
+ // a clip whose start is off-surface keeps its box on the surface.
294
+ // Content is right-aligned, so a box too narrow for both clips the
295
+ // badge off (it is leftmost) and keeps the button.
296
+ const left = Math.max(0, x0)
297
+
298
+ return [(
299
+ <div
300
+ key={item.id}
301
+ className="pointer-events-none absolute z-20 flex items-center justify-end gap-1 overflow-hidden"
302
+ style={{ top: row.y, height: row.height, left, right }}
303
+ >
304
+ {/* Deliberately dark in BOTH modes, not `--editor-*` driven — same
305
+ reasoning as the preview staying black and the canvas' own
306
+ clip labels: this chip sits over the CLIP's pixels (arbitrary
307
+ video/filmstrip content), not over editor chrome, so what it
308
+ has to contrast against is footage brightness, not the host's
309
+ light/dark setting. A dark plate at 70% opacity reliably shows
310
+ its light foreground over footage of any brightness; flipping
311
+ it to a light plate in a light editor theme would remove that
312
+ guarantee for no benefit, since the editor's theme has no
313
+ relationship to what's playing under it. Do not "fix" this to
314
+ use `--editor-surface`. */}
315
+ {queued && (
316
+ <span className="rounded bg-gray-900/70 px-1 text-[10px] font-medium text-amber-300/80">queued</span>
317
+ )}
318
+ {showSubcut && (
319
+ <button
320
+ type="button"
321
+ className="pointer-events-auto cursor-pointer rounded bg-gray-900/70 p-0.5 text-gray-200 opacity-50 transition-opacity hover:opacity-100"
322
+ title="Subcut regenerate"
323
+ aria-label="Subcut regenerate"
324
+ onClick={(e) => { e.stopPropagation(); setSubcutClipId(subcutClipId === item.id ? null : item.id) }}
325
+ ><Scissors size={10} /></button>
326
+ )}
327
+ </div>
328
+ )]
329
+ }))}
330
+ </>
331
+ )
332
+ }
333
+
334
+ /** Human-readable labels for `EASING_NAMES` (@bycrux/timeline-core) — the
335
+ * keyframe-strip easing picker's six buttons. Kept as a plain lookup rather
336
+ * than a formatter, since 'ease-in-out' has no mechanical rule that reads
337
+ * better than a name written out by hand. */
338
+ const EASING_LABELS: Record<EasingName, string> = {
339
+ linear: 'Linear',
340
+ ease: 'Ease',
341
+ 'ease-in': 'Ease In',
342
+ 'ease-out': 'Ease Out',
343
+ 'ease-in-out': 'Ease In Out',
344
+ hold: 'Hold',
345
+ }
346
+
347
+ export default function Timeline({ project, clock, onProjectChange, onOverlayEdit, selectedIds = [], onSelectIds, onInspectClip, onInspectAudio, onEditCaption, rippleMode = false, previewAxis = false, onHoverScrub, resolveFilePath, getWaveformPeaks, getFilmstrip, regenEnabled, isClipQueued, renderSubcutRegen, modalOpen = false, onOpenGoToTime, actionsRef, mode = 'dark', onImportFilesToTimeline, pendingDrops }: TimelineProps) {
71
348
 
72
349
  // Click/shift-click handler — additive selection on shift or meta (cmd/ctrl).
73
- // Also enforces the item→caption half of the two selection models' mutual
74
- // exclusivity: selecting a real item clears `selectedCaptionId`, and so does
75
- // clicking empty track space (id === null) — otherwise a "deselect all" click
76
- // would clear the item handles but leave the caption's preview handles up,
77
- // and since nothing else clears caption selection there would be no way to
78
- // put a caption down at all. The other half (caption select clears
79
- // `selectedIds`) lives in VideoEditor's wrapped `onSelectCaption`, since a
80
- // caption can be selected from the preview too — outside Timeline entirely —
81
- // not just from CaptionTrackRow.
350
+ // Under D1, captions share `selectedIds` with everything else, so there is
351
+ // no separate caption-clearing step here anymore: a plain (non-additive)
352
+ // select REPLACES the whole array with just this id, which drops any caption
353
+ // id that was in it, and a click on empty space (id === null) clears the
354
+ // array outright. A shift-click still ADDS to whatever is already selected,
355
+ // so a caption can sit alongside clips/audio in one selection — the
356
+ // overlay-parity ability D1 was for. VideoEditor derives its own
357
+ // `selectedCaptionId` (the preview's caption-box target) from this same
358
+ // array; see its `handleSelectCaption`.
359
+ /**
360
+ * Skip / un-skip a whole visual track.
361
+ *
362
+ * Preview-then-commit, the same two-channel shape every other timeline edit
363
+ * uses: `onProjectChange` applies it live (transient — no save, no undo
364
+ * entry), `onOverlayEdit` commits it as ONE undo step. Writing only through
365
+ * `onProjectChange` would leave it unsaved until some unrelated gesture
366
+ * flushed it.
367
+ */
368
+ function handleToggleTrackEnabled(trackIdx: number, enabled: boolean) {
369
+ if (!onProjectChange) return
370
+ const normalized = normalizeTracks(project)
371
+ const tracks = (normalized.tracks ?? []).map((t, i) =>
372
+ i === trackIdx ? { ...t, enabled } : t,
373
+ )
374
+ const next = { ...normalized, tracks } as Project
375
+ onProjectChange(next)
376
+ onOverlayEdit?.(next)
377
+ }
378
+
379
+ /**
380
+ * Track-wide volume/mute, from the rail's settings popover (TrackGutter.tsx).
381
+ * Same preview-then-commit shape as `handleToggleTrackEnabled` above, split
382
+ * across two handlers because volume is a CONTINUOUS control (a slider drag
383
+ * must preview many times and commit once) while mute is a discrete toggle
384
+ * (one click, one commit) — mirrors CaptionListPanel's fontsize slider vs.
385
+ * its style buttons.
386
+ *
387
+ * This writes `VisualTrack.volume`/`.muted`; the value doesn't stay
388
+ * track-level from there. `effectiveItemAudio(track, item)` in
389
+ * timeline-model.ts folds it into each item's own volume/mute (multiply,
390
+ * either/or), and every playback and export path reads the FOLDED value at
391
+ * its own fold point: `clipGain` in `useVideoPlayback.ts`,
392
+ * `withTrackAudio` in `engine/scheduler.ts`, and the inline fold in
393
+ * `render.js` (`collectAllItems`, ~line 829).
394
+ */
395
+ function handleSetTrackVolume(trackIdx: number, volume: number, commit: boolean) {
396
+ if (!onProjectChange) return
397
+ const normalized = normalizeTracks(project)
398
+ const tracks = (normalized.tracks ?? []).map((t, i) =>
399
+ i === trackIdx ? { ...t, volume } : t,
400
+ )
401
+ const next = { ...normalized, tracks } as Project
402
+ onProjectChange(next)
403
+ if (commit) onOverlayEdit?.(next)
404
+ }
405
+
406
+ function handleSetTrackMuted(trackIdx: number, muted: boolean) {
407
+ if (!onProjectChange) return
408
+ const normalized = normalizeTracks(project)
409
+ const tracks = (normalized.tracks ?? []).map((t, i) =>
410
+ i === trackIdx ? { ...t, muted } : t,
411
+ )
412
+ const next = { ...normalized, tracks } as Project
413
+ onProjectChange(next)
414
+ onOverlayEdit?.(next)
415
+ }
416
+
417
+ /**
418
+ * Track-wide speed, from the rail's settings popover's Speed control
419
+ * (TrackGutter.tsx / TrackSettingsPopover.tsx). Unlike volume/mute
420
+ * above, speed isn't a `VisualTrack` setting — it lives on each video
421
+ * clip — so this folds `setClipSpeed` over every video item on the track
422
+ * into ONE project before committing, the same way a multi-item ripple
423
+ * delete produces a single undo entry rather than one per clip. A bulk,
424
+ * one-shot edit (mirrors the clip inspect modal's own "Save speed" button),
425
+ * not a live preview, so there's no `commit` flag to thread.
426
+ */
427
+ function handleApplyTrackSpeed(trackIdx: number, speed: number) {
428
+ if (!onProjectChange) return
429
+ const items = trackItems(project)[trackIdx] ?? []
430
+ let next = project
431
+ for (const item of items) {
432
+ if (item.type === 'video') next = setClipSpeed(next, item.id, speed)
433
+ }
434
+ if (rippleMode) next = collapseGaps(next)
435
+ onProjectChange(next)
436
+ onOverlayEdit?.(next)
437
+ }
438
+
439
+ /**
440
+ * Audio-LANE volume/mute, from the rail's settings popover. A lane can hold
441
+ * several `AudioTrack`s sharing one row (see `groupAudioLanes` in
442
+ * timeline-model.ts), so the popover's single control fans out to every
443
+ * track id in the lane via the existing `updateAudioTrack` helper — unlike
444
+ * the visual-track handlers above, `AudioTrack.volume`/`.muted` are already
445
+ * live end to end (mix-audio.js, both preview paths), no separate fold step
446
+ * needed.
447
+ */
448
+ function handleSetLaneVolume(trackIds: string[], volume: number, commit: boolean) {
449
+ if (!onProjectChange) return
450
+ let next = project
451
+ for (const id of trackIds) next = updateAudioTrack(next, id, { volume })
452
+ onProjectChange(next)
453
+ if (commit) onOverlayEdit?.(next)
454
+ }
455
+
456
+ function handleSetLaneMuted(trackIds: string[], muted: boolean) {
457
+ if (!onProjectChange) return
458
+ let next = project
459
+ for (const id of trackIds) next = updateAudioTrack(next, id, { muted })
460
+ onProjectChange(next)
461
+ onOverlayEdit?.(next)
462
+ }
463
+
464
+ /**
465
+ * Audio-LANE magnet toggle — same fan-out as mute above, since a lane can
466
+ * hold several `AudioTrack`s. Turning it ON also collapses the lane
467
+ * immediately (`reflowMagneticLanes`), so flipping the switch is itself an
468
+ * edit rather than something that waits for the next drag to take effect.
469
+ * Turning it OFF just clears the flag; a lane already gapless stays exactly
470
+ * where it is.
471
+ */
472
+ function handleSetLaneMagnet(trackIds: string[], magnetic: boolean) {
473
+ if (!onProjectChange) return
474
+ let next = project
475
+ for (const id of trackIds) next = updateAudioTrack(next, id, { magnetic })
476
+ if (magnetic) next = reflowMagneticLanes(next)
477
+ onProjectChange(next)
478
+ onOverlayEdit?.(next)
479
+ }
480
+
481
+ /**
482
+ * The fade-shape picker: a small DOM menu (Linear / Logarithmic /
483
+ * Exponential) that opens on a RIGHT-CLICK of a fade grip (Vegas' own
484
+ * gesture — see `TimelineCanvas`'s `contextMenu` handler, which is the only
485
+ * caller of `setFadeCurveMenu`). `x`/`y` are CLIENT coordinates, so the
486
+ * menu below positions itself with `position: fixed` rather than measuring
487
+ * anything on this component's own tree.
488
+ */
489
+ const [fadeCurveMenu, setFadeCurveMenu] = useState<{ trackId: string; side: 'in' | 'out'; x: number; y: number } | null>(null)
490
+
491
+ /**
492
+ * Commit a fade's shape — a discrete, one-shot pick like
493
+ * `handleSetLaneMagnet` above, not a continuous drag, so `onProjectChange`
494
+ * + `onOverlayEdit` fire together as ONE undo entry rather than splitting
495
+ * across a preview/commit pair.
496
+ */
497
+ function handleSetFadeCurve(trackId: string, side: 'in' | 'out', curve: FadeCurve) {
498
+ setFadeCurveMenu(null)
499
+ if (!onProjectChange) return
500
+ const next = updateAudioTrack(project, trackId, side === 'in' ? { fadeInCurve: curve } : { fadeOutCurve: curve })
501
+ onProjectChange(next)
502
+ onOverlayEdit?.(next)
503
+ }
504
+
505
+ /**
506
+ * The keyframe-strip popup (SP9b T3.3) — `fadeCurveMenu`'s exact sibling: a
507
+ * small DOM menu that opens on a RIGHT-CLICK of a keyframe-strip diamond
508
+ * (`TimelineCanvas`'s `contextMenu` handler, the only caller of
509
+ * `setKeyframeMenu`). `x`/`y` are CLIENT coordinates for the same
510
+ * `position: fixed` reason. `props` is every keyframe track that has a
511
+ * point at `t` — a diamond can represent several props sharing one instant
512
+ * (the "union of times" the strip draws — see `keyframe-strip.ts`) — so
513
+ * every action below applies to ALL of them at once. `isLast` is true when
514
+ * `t` is the item's LAST keyframe: its easing has no next keyframe to
515
+ * reach, so the picker below disables (not hides) those six buttons then.
516
+ */
517
+ const [keyframeMenu, setKeyframeMenu] = useState<{ itemId: string; t: number; props: KeyframeProp[]; isLast: boolean; x: number; y: number } | null>(null)
518
+
519
+ /**
520
+ * The selected keyframe diamond, or null. Kind-agnostic: a diamond is a
521
+ * TIME on an item, so the props at that time are derived when acted on
522
+ * rather than stored here. Lives in Timeline rather than `VideoEditor`
523
+ * because every clearing trigger is already visible here and nothing
524
+ * outside the timeline consumes it.
525
+ */
526
+ const [selectedKeyframe, setSelectedKeyframe] = useState<KeyframeSelection | null>(null)
527
+
528
+ // Clear when the owning item stops being selected. A keyframe selection is
529
+ // meaningless without its item selected — the strip is not even drawn then
530
+ // (draw.ts gates on selection), so a stale selection would be invisible and
531
+ // still armed for Delete.
532
+ useEffect(() => {
533
+ if (selectedKeyframe && !selectedIds.includes(selectedKeyframe.itemId)) setSelectedKeyframe(null)
534
+ }, [selectedIds, selectedKeyframe])
535
+
536
+ /**
537
+ * Whole-item edit, for an op that derives its own affected props (unlike
538
+ * `applyToKeyframedItem` below, which threads a function per prop) — e.g.
539
+ * `removeKeyframesAt`, which decides itself which prop tracks have a point
540
+ * at `t`. Same commit shape as `applyToKeyframedItem`: one `onProjectChange`
541
+ * + one `onOverlayEdit` = one undo entry. Skips the commit entirely when
542
+ * `fn` returns the SAME item reference (no prop actually had anything to
543
+ * change) — `removeKeyframesAt`'s reference-equality no-op — so a Delete
544
+ * that couldn't find its keyframe never spends an undo entry on nothing.
545
+ */
546
+ function applyToItem(itemId: string, fn: (item: VisualItem) => VisualItem) {
547
+ setKeyframeMenu(null)
548
+ if (!onProjectChange) return
549
+ let changed = false
550
+ const tracks = mapTrackItems(project, items => items.map(item => {
551
+ if (item.id !== itemId) return item
552
+ const next = fn(item)
553
+ if (next !== item) changed = true
554
+ return next
555
+ }))
556
+ if (!changed) return
557
+ const next = { ...project, tracks } as Project
558
+ onProjectChange(next)
559
+ onOverlayEdit?.(next)
560
+ }
561
+
562
+ /**
563
+ * Apply `fn` to every prop the diamond at `t` represents, on the item
564
+ * `itemId`, as ONE commit — same shape as `handleSetFadeCurve` above: a
565
+ * discrete, one-shot pick, so `onProjectChange` + `onOverlayEdit` fire
566
+ * together as a single undo entry. Routes through `mapTrackItems`, the
567
+ * SAME item-patch mechanism the canvas pointer machine's own keyframe-drag
568
+ * gesture uses (`pointer-machine.ts`'s `replaceVisualItem`) — no second
569
+ * persistence path for keyframe edits.
570
+ */
571
+ function applyToKeyframedItem(itemId: string, props: KeyframeProp[], fn: (prop: KeyframeProp) => (item: VisualItem) => VisualItem) {
572
+ setKeyframeMenu(null)
573
+ if (!onProjectChange) return
574
+ const tracks = mapTrackItems(project, items => items.map(item => {
575
+ if (item.id !== itemId) return item
576
+ let next = item
577
+ for (const prop of props) next = fn(prop)(next)
578
+ return next
579
+ }))
580
+ const next = { ...project, tracks } as Project
581
+ onProjectChange(next)
582
+ onOverlayEdit?.(next)
583
+ }
584
+
585
+ function handleRemoveKeyframe(itemId: string, t: number) {
586
+ // Clearing rule 2 (plan Task 3): the keyframe is gone, so the selection
587
+ // naming it must go too — the menu is the OTHER route to the same removal
588
+ // the Delete binding performs, and the two must not disagree.
589
+ if (selectedKeyframe && selectedKeyframe.itemId === itemId && selectedKeyframe.t === t) setSelectedKeyframe(null)
590
+ applyToItem(itemId, item => removeKeyframesAt(item, t))
591
+ }
592
+
593
+ function handleSetKeyframeEasing(itemId: string, t: number, props: KeyframeProp[], easing: EasingName) {
594
+ applyToKeyframedItem(itemId, props, prop => item => setKeyframeEasing(item, prop, t, easing))
595
+ }
596
+
82
597
  function handleSelectItem(id: string | null, additive: boolean) {
83
598
  if (!onSelectIds) return
84
- onSelectCaption?.(null)
85
599
  if (id === null) { onSelectIds([]); return }
86
600
  onSelectIds(toggleSelection(selectedIds, id, additive))
87
601
  }
88
- const allTracks = project.tracks ?? []
602
+
603
+ /**
604
+ * A marquee's whole catch, applied at once.
605
+ *
606
+ * Deliberately a SET operation rather than a fold of `toggleSelection`:
607
+ * toggling would flip off any item that was already selected and happens to
608
+ * fall inside the box, so dragging a marquee over an existing selection would
609
+ * deselect exactly the clips it visibly covers. A marquee states what is
610
+ * selected; additive unions it with what was there.
611
+ */
612
+ function handleSelectItems(ids: string[], additive: boolean) {
613
+ if (!onSelectIds) return
614
+ onSelectIds(additive ? [...new Set([...selectedIds, ...ids])] : ids)
615
+ }
616
+ const allTracks = trackItems(project)
89
617
  const captionTrack = project.captions
90
618
  const audioTracks = project.audio?.tracks ?? []
91
619
 
92
620
  // Memoized so playback ticks (which re-render Timeline via the ctx useMemo's
93
621
  // clock dependency) don't recompute these on every frame — they only change
94
- // when the underlying tracks/audio actually change.
95
- const { snapBoundaries, contentDuration, totalDuration } = useMemo(() => {
96
- const snapBoundaries = [...new Set([
97
- ...allTracks.flat().flatMap(c => [c.start, c.end]),
98
- ...audioTracks.flatMap(t => [t.start, t.end]),
99
- ])]
100
- const contentDuration = Math.max(
101
- allTracks.flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
102
- audioTracks.reduce((m, t) => Math.max(m, t.end ?? 0), 0),
103
- )
104
- // Add 20% padding beyond content so the rightmost item can always be
105
- // dragged or resized further out. Minimum 5s headroom.
106
- const totalDuration = contentDuration + Math.max(5, contentDuration * 0.2)
107
- return { snapBoundaries, contentDuration, totalDuration }
108
- }, [project.tracks, project.audio])
109
-
110
- // Auto-crossfade: when two audio tracks overlap, apply fade-out on the earlier
111
- // and fade-in on the later, each equal to the overlap duration.
622
+ // when the underlying tracks/audio actually change. Both the DOM and canvas
623
+ // (T4) track-row areas import this from timeline-model.ts, so timing can't
624
+ // drift between the two.
625
+ const { snapBoundaries, contentDuration, totalDuration } = useMemo(
626
+ () => computeDerivedTiming(project),
627
+ [project.tracks, project.audio],
628
+ )
629
+
630
+ // Auto-crossfade: when two audio tracks overlap, apply fade-out on the
631
+ // earlier and fade-in on the later, each equal to the overlap duration. The
632
+ // decision logic lives in timeline-model.ts (computeAutoCrossfade) so both
633
+ // the DOM and canvas (T4) track-row areas share it; this effect is a thin
634
+ // shell that applies the result.
112
635
  useEffect(() => {
113
- if (!audioTracks.length || !onProjectChange) return
114
- const sorted = [...audioTracks].sort((a, b) => a.start - b.start)
115
- let changed = false
116
- const updated = sorted.map(t => ({ ...t }))
117
-
118
- // We only auto-set fades where overlap exists
119
- for (let i = 0; i < updated.length - 1; i++) {
120
- const a = updated[i]
121
- const b = updated[i + 1]
122
- if (a.end > b.start && !a.muted && !b.muted) {
123
- // Overlap detected
124
- const overlap = Math.min(a.end - b.start, a.end - a.start, b.end - b.start)
125
- if ((a.fadeOut ?? 0) !== overlap) {
126
- a.fadeOut = Math.round(overlap * 10) / 10 // round to 0.1s
127
- changed = true
128
- }
129
- if ((b.fadeIn ?? 0) !== overlap) {
130
- b.fadeIn = Math.round(overlap * 10) / 10
131
- changed = true
132
- }
133
- }
134
- }
636
+ if (!onOverlayEdit) return
637
+ const next = computeAutoCrossfade(project)
638
+ if (!next) return
639
+ // Commit the derived crossfade on a short delay, NEVER synchronously per
640
+ // change. Audio timing moves on every mousemove of a drag, and committing
641
+ // each one recorded dozens of undo entries for a single gesture — audio's
642
+ // version of the per-move-undo bug the video-move commit split fixed. A
643
+ // drag's own commit (`commitTimelineEdit`) now folds the crossfade into its
644
+ // one undo step, so this debounced pass is only the catch-all for audio
645
+ // timing that changes OUTSIDE a gesture (ripple-delete, gap-collapse): mid-
646
+ // drag the timer is cleared and rescheduled every frame and never fires,
647
+ // and right after a gesture it no-ops because `computeAutoCrossfade` is
648
+ // idempotent. The digest keying this effect includes the FADES on purpose —
649
+ // the gesture's own crossfade commit changes it, which re-runs this effect
650
+ // and clears the pending timer rather than letting a stale one fire. That
651
+ // is also why this only commits and no longer previews via `onProjectChange`:
652
+ // a preview would change the fade digest and clear the timer before it
653
+ // fired, so a ripple-delete's crossfade would never get saved.
654
+ const timer = setTimeout(() => onOverlayEdit(next), CROSSFADE_COMMIT_DELAY_MS)
655
+ return () => clearTimeout(timer)
656
+ // Keyed on a stable digest of audio-track timing/mute AND fades, so the pass
657
+ // re-runs on real edits (see above for why the fades belong in the key).
658
+ }, [audioTracks.map(t => `${t.id}:${t.start}:${t.end}:${t.muted}:${t.fadeIn ?? ''}:${t.fadeOut ?? ''}`).join('|')])
135
659
 
136
- if (changed) {
137
- const trackMap = new Map(updated.map(t => [t.id, t]))
138
- const nextProject: typeof project = {
139
- ...project,
140
- audio: {
141
- ...project.audio,
142
- tracks: (project.audio?.tracks ?? []).map(t => trackMap.get(t.id) ?? t),
143
- },
144
- }
145
- onProjectChange(nextProject)
146
- }
147
- // Intentionally keyed on a stable digest of audio-track timing/mute rather
148
- // than the array identity, so the crossfade pass only re-runs on real edits.
149
- }, [audioTracks.map(t => `${t.id}:${t.start}:${t.end}:${t.muted}`).join('|')])
660
+ // The tabIndex={0} root below — Delete/Enter's guards below check focus is
661
+ // inside it before firing (see the useKeymap block), restoring the pre-T9
662
+ // scoping those two bindings had (arrows/Escape were already document-level
663
+ // pre-SP5 and stay that way — see the useKeymap block's own comment).
664
+ const rootRef = useRef<HTMLDivElement>(null)
150
665
 
151
- const [hoverPct, setHoverPct] = useState<number | null>(null)
152
- const [draggingPlayhead, setDraggingPlayhead] = useState(false)
153
- const [markers, setMarkers] = useState<[number | null, number | null]>([null, null])
154
- const [transcriptModalOpen, setTranscriptModalOpen] = useState(false)
666
+ const [subcutClipId, setSubcutClipId] = useState<string | null>(null)
155
667
 
156
- const scrubberRef = useRef<HTMLDivElement>(null)
157
- const overlayDraggedRef = useRef(false)
158
- const [keyNavTime, setKeyNavTime] = useState<number | null>(null)
159
- const keyNavTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
668
+ const { scrollRef } = useTimelineZoom()
160
669
 
161
- const [subcutClipId, setSubcutClipId] = useState<string | null>(null)
670
+ // ── Zoom chrome ──
671
+ // The canvas surface pans/zooms a px-per-second viewport; these three
672
+ // buttons drive it through `useCanvasZoomControls`. This used to be an
673
+ // adapter over two zoom models (the retired DOM rows zoomed a scroll
674
+ // container by a multiplier instead) — there is only the one now.
675
+ const viewportStore = useViewportStore()
676
+ const zoomControls: ZoomControls = useCanvasZoomControls(viewportStore, totalDuration)
162
677
 
163
- const { zoom, zoomRef, scrollRef, zoomTo } = useTimelineZoom(totalDuration)
678
+ // ── Timeline's own keymap — arrows (frame-step) and delete (two-step:
679
+ // deleteSelection + conditional collapseGaps). SP5 T9: these bindings
680
+ // replace Timeline's old ad hoc document listener (arrows, gated on
681
+ // totalDuration>0 by not registering at all) and its container-scoped
682
+ // onKeyDown (delete) with the shared registry — one guarded document
683
+ // listener instead of two different attachment mechanisms with two
684
+ // different guard sets.
685
+ //
686
+ // Mounted HERE, not lifted into VideoEditor's keymap: this instance runs in
687
+ // BOTH the pending and review surfaces (PendingSurface and ReviewSurface
688
+ // each render their own `<Timeline>` with their own `PlaybackClock`), same
689
+ // as the listeners it replaces. `modalOpen` is the host's dialogs
690
+ // (ReviewSurface's RenderModal/palette/etc — absent in PendingSurface).
691
+ const fps = project.settings?.fps ?? 30
692
+ const frameStep = 1 / fps
693
+ useKeymap([
694
+ // Cmd/Ctrl+Arrow jump-to-start/end. `matchesArrowLeft`/`matchesArrowRight`
695
+ // (keymap.ts's `matchesKey`) don't exclude modifiers, so a mod+arrow chord
696
+ // matches BOTH these bindings AND `timeline.frame-step` below — these two
697
+ // are listed FIRST so first-match-wins picks the jump, not a frame step
698
+ // (same precedence pattern documented on `matchesModAltKey` in keymap.ts).
699
+ {
700
+ id: 'timeline.jump-start',
701
+ description: 'Jump to start',
702
+ matches: matchesModKey('ArrowLeft'),
703
+ guard: () => totalDuration > 0,
704
+ action: () => {
705
+ clock.set(0)
706
+ },
707
+ },
708
+ {
709
+ id: 'timeline.jump-end',
710
+ description: 'Jump to end',
711
+ matches: matchesModKey('ArrowRight'),
712
+ guard: () => totalDuration > 0,
713
+ // `contentDuration` is the furthest item's end (max across every clip/
714
+ // overlay/audio track) — NOT `totalDuration`, which pads content with
715
+ // scroll/drop headroom (see computeDerivedTiming in timeline-model.ts).
716
+ // The jump belongs at the last real element, not into that headroom.
717
+ action: () => {
718
+ clock.set(contentDuration)
719
+ },
720
+ },
721
+ {
722
+ id: 'timeline.frame-step',
723
+ description: 'Step one frame (Shift steps ten)',
724
+ matches: (e) => matchesArrowLeft(e) || matchesArrowRight(e),
725
+ guard: () => totalDuration > 0,
726
+ action: (e) => {
727
+ // Shift is a COARSE frame step, not a wall-clock jump: ten frames,
728
+ // so the unit stays the same as the plain step and only the size
729
+ // changes. It used to be a flat 1 second, which meant the shifted
730
+ // step drifted against the unshifted one on every fps that isn't 10.
731
+ const step = e.shiftKey ? 10 * frameStep : frameStep
732
+ const dir = matchesArrowRight(e) ? 1 : -1
733
+ const next = Math.max(0, Math.min(totalDuration, clock.get() + dir * step))
734
+ clock.set(next)
735
+ },
736
+ },
737
+ {
738
+ id: 'timeline.delete-keyframe',
739
+ description: 'Delete selected keyframe',
740
+ matches: matchesDelete,
741
+ // Ordered BEFORE `timeline.delete-selection` and guarded on a keyframe
742
+ // actually being selected: useKeymap is first-match-wins (keymap.ts:80-86),
743
+ // so with no keyframe selected this falls straight through to clip
744
+ // deletion. The focus scope matches the binding below it exactly — a
745
+ // Delete typed outside the timeline must not reach either.
746
+ guard: () => {
747
+ if (!selectedKeyframe || !rootRef.current?.contains(document.activeElement)) return false
748
+ // A selection can go stale WITHOUT its item leaving `selectedIds`:
749
+ // dragging the diamond retimes it, the right-click menu removes it,
750
+ // undo rewinds it, a split moves it to the other half. `drawKeyframeStrip`
751
+ // matches on `t`, so nothing draws as selected then — and Delete must do
752
+ // what the operator SEES (delete the clip) rather than swallow the press
753
+ // on a keyframe that no longer exists.
754
+ const item = trackItems(project).flat().find(i => i.id === selectedKeyframe.itemId)
755
+ return !!item && keyframeUnionTimes(item).includes(selectedKeyframe.t)
756
+ },
757
+ action: () => {
758
+ if (!selectedKeyframe) return
759
+ const { itemId, t } = selectedKeyframe
760
+ setSelectedKeyframe(null)
761
+ applyToItem(itemId, item => removeKeyframesAt(item, t))
762
+ },
763
+ },
764
+ {
765
+ id: 'timeline.delete-selection',
766
+ description: 'Delete selection',
767
+ matches: matchesDelete,
768
+ // Focus-scoped (unlike arrows/Escape below): pre-SP5, delete lived on
769
+ // the container's own onKeyDown, so it only ever fired with the
770
+ // timeline focused. Restored here so a Backspace pressed elsewhere on
771
+ // the page (e.g. typing in the preview's overlay panel) can't delete
772
+ // the selected clip out from under the operator.
773
+ guard: () => selectedIds.length > 0 && !!rootRef.current?.contains(document.activeElement),
774
+ action: () => {
775
+ if (!onProjectChange) return
776
+ let updated = deleteSelection(project, selectedIds)
777
+ // `deleteSelection` only knows tracks/audio vocabulary (see
778
+ // multiSelectOps.ts) — captions never lived in project.tracks, so a
779
+ // selected caption segment needs its own strip here, folded into the
780
+ // SAME commit as the clip/audio delete (one onProjectChange + one
781
+ // onOverlayEdit = one undo entry covering a mixed selection).
782
+ if (captionTrack?.segments?.length) {
783
+ const targets = new Set(selectedIds)
784
+ const kept = captionTrack.segments.filter(seg => !seg.id || !targets.has(seg.id))
785
+ // Leave captions as an EMPTY-segments object when every segment is
786
+ // removed, never null the whole track — that's the sidebar's
787
+ // explicit "Remove all" action, not a side effect of Delete.
788
+ //
789
+ // `normalizeCaptionLanes` runs INSIDE this same commit: emptying a
790
+ // caption row leaves a hole lane, and collapsing it here means the
791
+ // row disappearing and its neighbours renumbering are the same undo
792
+ // entry as the delete that caused them, not a second one.
793
+ if (kept.length !== captionTrack.segments.length) {
794
+ updated = { ...updated, captions: normalizeCaptionLanes({ ...captionTrack, segments: kept }) }
795
+ }
796
+ }
797
+ if (rippleMode) updated = collapseGaps(updated)
798
+ // Deleting a clip out of a magnetic audio lane leaves a gap exactly
799
+ // like a trim or a move would — close it the same way, on release.
800
+ updated = reflowMagneticLanes(updated)
801
+ onProjectChange(updated)
802
+ onOverlayEdit?.(updated)
803
+ onSelectIds?.([])
804
+ },
805
+ },
806
+ ], { modalOpen })
164
807
 
808
+ // Close the fade-curve picker on Escape — the backdrop click below (and a
809
+ // pick itself) already closes it any other way a user would try.
165
810
  useEffect(() => {
166
- if (totalDuration === 0) return
167
- const fps = project.settings?.fps ?? 30
168
- const frame = 1 / fps
169
- const onKey = (e: globalThis.KeyboardEvent) => {
170
- // While the transcript modal is open, or the caret is in editable text
171
- // (caption segments are contentEditable), arrows move the text cursor —
172
- // never the playhead.
173
- if (transcriptModalOpen) return
174
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
175
- if ((e.target as HTMLElement).isContentEditable) return
176
- if (e.key === 'Escape') { setMarkers([null, null]); return }
177
- if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
178
- e.preventDefault()
179
- const step = e.shiftKey ? 1 : frame
180
- const dir = e.key === 'ArrowRight' ? 1 : -1
181
- const next = Math.max(0, Math.min(totalDuration, clock.get() + dir * step))
182
- clock.set(next)
183
- setKeyNavTime(next)
184
- if (keyNavTimerRef.current) clearTimeout(keyNavTimerRef.current)
185
- keyNavTimerRef.current = setTimeout(() => setKeyNavTime(null), 1500)
186
- }
187
- document.addEventListener('keydown', onKey)
188
- return () => document.removeEventListener('keydown', onKey)
189
- }, [totalDuration, clock, project.settings?.fps, transcriptModalOpen])
811
+ if (!fadeCurveMenu) return
812
+ const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setFadeCurveMenu(null) }
813
+ document.addEventListener('keydown', onKeyDown)
814
+ return () => document.removeEventListener('keydown', onKeyDown)
815
+ }, [fadeCurveMenu])
190
816
 
191
- // Derive selection from two placed markers
192
- const selection = markers[0] !== null && markers[1] !== null
193
- ? { start: Math.min(markers[0], markers[1]), end: Math.max(markers[0], markers[1]) }
194
- : null
817
+ // `fadeCurveMenu`'s Escape handling, sibling for sibling.
818
+ useEffect(() => {
819
+ if (!keyframeMenu) return
820
+ const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setKeyframeMenu(null) }
821
+ document.addEventListener('keydown', onKeyDown)
822
+ return () => document.removeEventListener('keydown', onKeyDown)
823
+ }, [keyframeMenu])
195
824
 
196
- const ctx = useMemo<TimelineContextValue>(() => ({
197
- totalDuration, contentDuration, snapBoundaries, zoom, zoomRef, scrollRef, scrubberRef,
198
- overlayDraggedRef, clock, markers, setMarkers, selection,
199
- }), [totalDuration, contentDuration, snapBoundaries, zoom, zoomRef, scrollRef, scrubberRef,
200
- overlayDraggedRef, clock, markers, setMarkers, selection])
201
-
202
- function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
203
- if ((e.target as HTMLElement).isContentEditable) return
204
-
205
- if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) {
206
- e.preventDefault()
207
- if (!onProjectChange) return
208
- let updated = deleteSelection(project, selectedIds)
209
- if (rippleMode) updated = collapseGaps(updated)
210
- onProjectChange(updated)
211
- onOverlayEdit?.(updated)
212
- onSelectIds?.([])
213
- return
214
- }
825
+ // Hand the zoom action up to a host-level command palette.
826
+ useEffect(() => {
827
+ if (!actionsRef) return
828
+ actionsRef.current = { zoomFit: zoomControls.fit }
829
+ return () => { actionsRef.current = null }
830
+ })
215
831
 
216
- if (e.key !== 'Enter' || totalDuration === 0) return
217
- e.preventDefault()
218
- const t = clock.get()
219
- setMarkers(([a, b]) => {
220
- if (a === null) return [t, null]
221
- if (b === null) return [a, t]
222
- return [t, null]
223
- })
224
- }
832
+ // Same layout the canvas paints from, so the rail's rows line up with the
833
+ // clips exactly rather than by two independent calculations agreeing.
834
+ const canvasLayout = useMemo(() => computeTimelineLayout(project), [project.tracks, project.audio, project.captions])
225
835
 
836
+ const ctx = useMemo<TimelineContextValue>(() => ({
837
+ contentDuration, clock,
838
+ }), [contentDuration, clock])
839
+
840
+ /**
841
+ * Clicks that land on the timeline column but not on any row: the readout
842
+ * strip above the tracks, the gaps between rows, the space under the last
843
+ * one. They are background, and background belongs to the timeline — not
844
+ * dead chrome.
845
+ *
846
+ * This reads the canvas' own x-axis — the only time axis there is now that
847
+ * the scrubber's overview bar is gone — so a background click does exactly
848
+ * what pressing bare canvas does: clear the selection, and seek to the
849
+ * clicked time. Clicks to the left of the canvas (over the track rail,
850
+ * which has no time axis) clear the selection without seeking. The canvas
851
+ * surface itself stops propagation — the pointer machine has already seeked
852
+ * on mousedown — so nothing here double-handles a real timeline click.
853
+ *
854
+ * `pointer-events` note: the controls in the strip (zoom buttons, the go-to
855
+ * time readout) are excluded by the `closest` test below, so isolating them
856
+ * costs nothing beyond it.
857
+ */
226
858
  function handleContainerClick(e: React.MouseEvent) {
227
- if ((e.target as HTMLElement).closest('button, input, [contenteditable]')) return
859
+ if ((e.target as HTMLElement).closest('button, input, [contenteditable], [data-timeline-chrome]')) return
228
860
  if (totalDuration === 0) return
229
- const rect = scrubberRef.current?.getBoundingClientRect()
230
- if (!rect) return
231
- const clickedTime = ratioFromClientX(e.clientX, rect) * totalDuration
232
- const snapThreshold = (8 / rect.width) * totalDuration
233
- const boundaries = snapBoundaries
234
- for (const b of boundaries) {
235
- if (Math.abs(clickedTime - b) < snapThreshold) { clock.set(b); return }
236
- }
237
- clock.set(clickedTime)
238
- }
239
861
 
240
- const cutButtonLabel = primarySelectedId
241
- ? `Cut ${allTracks.flat().find(i => i.id === primarySelectedId)?.type ?? 'item'}`
242
- : 'Cut primary'
862
+ // Same order the pointer machine emits its effects in for a press on
863
+ // bare canvas: clear the selection, then seek.
864
+ handleSelectItem(null, false)
865
+ const surface = rootRef.current?.querySelector('[data-timeline-canvas]')
866
+ if (!surface) return
867
+ const surfaceRect = surface.getBoundingClientRect()
868
+ const x = e.clientX - surfaceRect.left
869
+ if (x < 0 || x > surfaceRect.width) return // over the rail — no time here
870
+ // `.get()`, not a subscription: this is an event handler, not something
871
+ // that renders, and `get()` is the more correct read anyway — mid-pan the
872
+ // last value React committed can lag a frame behind the store.
873
+ clock.set(Math.max(0, Math.min(totalDuration, xToTime(x, viewportStore.get()))))
874
+ }
243
875
 
244
876
  return (
245
877
  <TimelineContext.Provider value={ctx}>
246
878
  <div
247
- className="flex flex-col gap-2 px-3 py-3 select-none outline-none"
879
+ ref={rootRef}
880
+ // `min-h-full` so the column always fills the resizable timeline pane:
881
+ // that is what lets the transcript panel below sit at the BOTTOM of the
882
+ // pane rather than floating right under the tracks, and it makes the
883
+ // space between them read as timeline surface instead of page
884
+ // background. Content taller than the pane still scrolls.
885
+ className="flex min-h-full flex-col gap-2 px-3 py-3 select-none outline-none"
248
886
  tabIndex={0}
249
- onKeyDown={handleKeyDown}
250
- onMouseMove={(e) => {
251
- const rect = scrubberRef.current?.getBoundingClientRect()
252
- if (rect) setHoverPct(ratioFromClientX(e.clientX, rect) * 100)
253
- }}
254
- onMouseLeave={() => setHoverPct(null)}
255
887
  onClick={handleContainerClick}
256
888
  >
257
889
 
258
890
  {/* Zoom controls */}
259
891
  {totalDuration > 0 && (
260
- <div className="flex items-center justify-end gap-0.5 -mb-1">
261
- <button
262
- className="text-[11px] leading-none text-gray-500 hover:text-gray-300 w-5 h-5 flex items-center justify-center rounded hover:bg-gray-800 transition-colors"
263
- title="Zoom out"
264
- onClick={(e) => { e.stopPropagation(); zoomTo(zoomRef.current - 1) }}
265
- >−</button>
266
- <span className="text-[10px] font-mono text-gray-500 w-7 text-center tabular-nums select-none">{zoom}×</span>
267
- <button
268
- className="text-[11px] leading-none text-gray-500 hover:text-gray-300 w-5 h-5 flex items-center justify-center rounded hover:bg-gray-800 transition-colors"
269
- title="Zoom in"
270
- onClick={(e) => { e.stopPropagation(); zoomTo(zoomRef.current + 1) }}
271
- >+</button>
272
- {zoom > 1 && (
892
+ <div className="flex items-center justify-end gap-0.5 -mb-1 cursor-pointer">
893
+ <Tooltip label="Zoom out">
894
+ <button
895
+ className="text-[11px] leading-none text-[var(--editor-text)]/45 hover:text-[var(--editor-text)]/80 w-5 h-5 flex items-center justify-center rounded hover:bg-[var(--editor-text)]/10 transition-colors"
896
+ aria-label="Zoom out"
897
+ onClick={(e) => { e.stopPropagation(); zoomControls.zoomOut() }}
898
+ >−</button>
899
+ </Tooltip>
900
+ {zoomControls.badge}
901
+ <Tooltip label="Zoom in">
273
902
  <button
274
- className="text-[10px] text-gray-500 hover:text-gray-300 px-1.5 h-5 rounded hover:bg-gray-800 transition-colors ml-0.5"
275
- title="Fit to view"
276
- onClick={(e) => { e.stopPropagation(); zoomTo(1) }}
277
- >fit</button>
903
+ className="text-[11px] leading-none text-[var(--editor-text)]/45 hover:text-[var(--editor-text)]/80 w-5 h-5 flex items-center justify-center rounded hover:bg-[var(--editor-text)]/10 transition-colors"
904
+ aria-label="Zoom in"
905
+ onClick={(e) => { e.stopPropagation(); zoomControls.zoomIn() }}
906
+ >+</button>
907
+ </Tooltip>
908
+ {zoomControls.showFit && (
909
+ <Tooltip label="Fit to view" className="ml-0.5">
910
+ <button
911
+ className="text-[10px] text-[var(--editor-text)]/45 hover:text-[var(--editor-text)]/80 px-1.5 h-5 rounded hover:bg-[var(--editor-text)]/10 transition-colors"
912
+ aria-label="Fit to view"
913
+ onClick={(e) => { e.stopPropagation(); zoomControls.fit() }}
914
+ >fit</button>
915
+ </Tooltip>
278
916
  )}
279
917
  </div>
280
918
  )}
281
919
 
282
- {/* Scroll container for zoomed tracks */}
920
+ {/* Horizontal overflow guard. This used to be a zoom viewport: the DOM
921
+ rows were laid out as percentages of the whole project, so zooming
922
+ meant widening an inner div to `zoom × 100%` and scrolling this
923
+ container over it. The canvas surface zooms its own px-per-second
924
+ viewport instead and always fits the width it is given, so nothing
925
+ in here is ever wider than the pane — this stays only as the guard
926
+ that keeps an unexpectedly wide child from stretching the page. */}
283
927
  <div ref={scrollRef} className="overflow-x-auto">
284
- <div style={{ width: zoom > 1 ? `${zoom * 100}%` : '100%' }} className="min-w-full">
285
928
 
286
- {/* Scrubber + tracks wrapped in a relative container so the hover indicator spans the full height */}
929
+ {/* Readout strip + tracks, wrapped in a relative container */}
287
930
  <div className="relative flex flex-col gap-2">
288
- {hoverPct !== null && totalDuration > 0 && (
289
- <div
290
- className="absolute inset-y-0 w-px bg-yellow-400/80 pointer-events-none z-20"
291
- style={{ left: `${hoverPct}%` }}
292
- />
293
- )}
294
931
 
295
- <Scrubber
296
- hoverPct={hoverPct}
297
- draggingPlayhead={draggingPlayhead}
298
- setDraggingPlayhead={setDraggingPlayhead}
299
- keyNavTime={keyNavTime}
300
- onSplit={onSplit}
301
- onCut={onCut}
302
- cutButtonLabel={cutButtonLabel}
303
- />
932
+ <Scrubber onOpenGoToTime={onOpenGoToTime} />
304
933
 
305
934
  {/* ── Tracks ── */}
306
935
  <div className="flex flex-col gap-1">
307
936
  {project.renderMode === 'ffmpeg-drawtext' && (
308
- <div className="flex items-center gap-1.5 px-2 py-1 rounded bg-amber-500/10 border border-amber-500/20 text-[10px] text-amber-400/70 select-none">
937
+ <div className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] select-none ${mode === 'light' ? 'bg-amber-50 border border-amber-300 text-amber-800' : 'bg-amber-500/10 border border-amber-500/20 text-amber-400/70'}`}>
309
938
  <span>⚡</span>
310
939
  <span>ffmpeg render — overlays are preview only, final text is burned by ffmpeg</span>
311
940
  </div>
312
941
  )}
313
- {/* ── Caption track — its own row above the visual tracks. NOT part of
314
- tracks[]; reads/writes project.captions directly (see
315
- CaptionTrackRow's file header for the special-track rationale). ── */}
316
- <CaptionTrackRow
317
- captionTrack={captionTrack}
318
- fps={project.settings?.fps ?? 30}
319
- selectedCaptionId={selectedCaptionId}
320
- onSelectCaption={onSelectCaption}
321
- onCaptionSegmentChange={onCaptionSegmentChange}
322
- />
323
-
324
- {[...allTracks].reverse().map((trackItems, reversedIdx) => {
325
- const trackIdx = allTracks.length - 1 - reversedIdx
326
- return (
327
- <VisualTrackRow
328
- key={trackIdx}
329
- trackItems={trackItems}
330
- trackIdx={trackIdx}
942
+ {/* The gutter is a sibling COLUMN, not an overlay: it takes width from
943
+ the canvas (which measures its own container) instead of painting
944
+ over the clips at t=0. Captions have their own row PAINTED inside
945
+ `TimelineCanvas` below, not a separate DOM element — the gutter's
946
+ own caption cell is sized from the same layout (`canvasLayout`) so
947
+ the two line up. `showCaptionRow` is left at its default (true)
948
+ here: `TrackGutter` already gates the cell on `resolved.captions`,
949
+ which `computeTimelineLayout` only sets when there ARE captions,
950
+ so passing `!!captionTrack?.segments?.length` on top can never
951
+ change the outcome — it exists as a host knob for other callers,
952
+ not for this one. */}
953
+ <div className="flex gap-1">
954
+ <TrackGutter
955
+ project={project}
956
+ layout={canvasLayout}
957
+ mode={mode}
958
+ onToggleTrackEnabled={handleToggleTrackEnabled}
959
+ onSetTrackVolume={handleSetTrackVolume}
960
+ onSetTrackMuted={handleSetTrackMuted}
961
+ onApplySpeed={handleApplyTrackSpeed}
962
+ onSetLaneVolume={handleSetLaneVolume}
963
+ onSetLaneMuted={handleSetLaneMuted}
964
+ onSetLaneMagnet={handleSetLaneMagnet}
965
+ />
966
+ {/* `relative` makes this column the positioning context for the
967
+ per-clip HTML chrome below — see `CanvasClipChrome`. It holds
968
+ exactly one in-flow child (the canvas surface, `w-full` with an
969
+ explicit height), so its content box IS that surface's box and
970
+ the chrome lands on the same pixels the painter does. */}
971
+ <div className="relative flex min-w-0 flex-1 flex-col gap-1">
972
+ {/* ── Canvas track-row area — one surface carrying the visual
973
+ tracks, audio lanes and caption rows. It owns its own
974
+ zoom/scroll (px-per-second), so the readout strip above it
975
+ stays a whole-project readout rather than tracking canvas
976
+ zoom. ── */}
977
+ <TimelineCanvas
331
978
  project={project}
979
+ clock={clock}
980
+ store={viewportStore}
981
+ totalDuration={totalDuration}
982
+ fps={fps}
332
983
  selectedIds={selectedIds}
984
+ snapBoundaries={snapBoundaries}
333
985
  rippleMode={rippleMode}
986
+ previewAxis={previewAxis}
987
+ onHoverScrub={onHoverScrub}
988
+ onSelectItem={handleSelectItem}
989
+ onSelectItems={handleSelectItems}
990
+ selectedKeyframe={selectedKeyframe}
991
+ onSelectKeyframe={setSelectedKeyframe}
334
992
  onProjectChange={onProjectChange}
335
993
  onOverlayEdit={onOverlayEdit}
336
- onEditOverlay={onEditOverlay}
337
- onSelectItem={handleSelectItem}
338
994
  onInspectClip={onInspectClip}
339
- subcutClipId={subcutClipId}
340
- setSubcutClipId={setSubcutClipId}
341
- regenEnabled={regenEnabled}
342
- isClipQueued={isClipQueued}
995
+ onInspectAudio={onInspectAudio}
996
+ onEditCaption={onEditCaption}
997
+ onFadeCurveMenu={setFadeCurveMenu}
998
+ onKeyframeMenu={setKeyframeMenu}
999
+ getWaveformPeaks={getWaveformPeaks}
1000
+ getFilmstrip={getFilmstrip}
1001
+ resolveFilePath={resolveFilePath}
1002
+ mode={mode}
1003
+ onImportFilesToTimeline={onImportFilesToTimeline}
1004
+ pendingDrops={pendingDrops}
343
1005
  />
344
- )
345
- })}
346
-
347
- {/* Audio tracks — grouped by lane */}
348
- {(() => {
349
- // Group audio tracks by lane. Tracks without a lane get auto-assigned.
350
- const laneMap = new Map<number, typeof audioTracks>()
351
- let nextAutoLane = 0
352
- for (const t of audioTracks) {
353
- if (t.lane != null && t.lane >= nextAutoLane) nextAutoLane = t.lane + 1
354
- }
355
- for (const t of audioTracks) {
356
- const lane = t.lane ?? nextAutoLane++
357
- if (!laneMap.has(lane)) laneMap.set(lane, [])
358
- laneMap.get(lane)!.push(t)
359
- }
360
- const lanes = [...laneMap.entries()].sort((a, b) => a[0] - b[0])
361
-
362
- return lanes.map(([laneIdx, laneTracks]) => (
363
- <AudioTrackRow
364
- key={`audio-lane-${laneIdx}`}
365
- tracks={laneTracks}
366
- laneIndex={laneIdx}
367
- laneCount={lanes.length}
368
- project={project}
369
- onProjectChange={onProjectChange}
370
- onOverlayEdit={onOverlayEdit}
1006
+ {/* ── Per-clip HTML chrome over the canvas ──
1007
+ The subcut-regenerate trigger and the "queued" badge, ported
1008
+ from the DOM clip rows. Passed the STORE, not a subscribed
1009
+ value: unlike the canvas itself, which reads `store.get()`
1010
+ inside its draw to avoid re-rendering, this chrome IS a DOM
1011
+ node that has to be re-laid-out by React on every viewport
1012
+ change or it detaches from its clip the moment you pan or
1013
+ zoom — so it subscribes internally, isolated to itself, the
1014
+ same way `CanvasZoomBadge` isolates its own subscription in
1015
+ TimelineCanvas.tsx. That isolation is the whole point: it
1016
+ keeps a wheel-zoom frame from re-rendering Timeline's entire
1017
+ subtree (this chrome does NOT share a subscription with the
1018
+ zoom badge — each owns its own). */}
1019
+ <CanvasClipChrome
1020
+ layout={canvasLayout}
1021
+ viewportStore={viewportStore}
371
1022
  selectedIds={selectedIds}
372
- onSelectItem={handleSelectItem}
373
- onInspect={onInspectAudio}
374
- getWaveformChunks={getWaveformChunks}
375
- resolveFilePath={resolveFilePath}
1023
+ regenEnabled={regenEnabled}
1024
+ isClipQueued={isClipQueued}
1025
+ subcutClipId={subcutClipId}
1026
+ setSubcutClipId={setSubcutClipId}
376
1027
  />
377
- ))
378
- })()}
1028
+ </div>
1029
+ </div>
379
1030
 
380
1031
  </div>
381
1032
 
382
1033
  </div>{/* end scrubber+tracks wrapper */}
383
- </div>{/* end inner zoom div */}
384
1034
  </div>{/* end scroll container */}
385
1035
 
386
1036
  {/* ── Subcut regen tool (host-rendered via render-prop seam) ──
@@ -398,54 +1048,137 @@ export default function Timeline({ project, clock, onProjectChange, onCaptionEdi
398
1048
  })
399
1049
  })()}
400
1050
 
401
- {/* ── Transcript editor ── */}
402
- {/* Wrapped in a clock-subscribing child so the active-segment highlight
403
- tracks the playhead WITHOUT Timeline (and its track rows) re-rendering
404
- every tick. */}
405
- <TranscriptPanelWithClock
406
- clock={clock}
407
- project={project}
408
- captionTrack={captionTrack}
409
- onCaptionEdit={onCaptionEdit}
410
- onProjectChange={onProjectChange}
411
- onExpand={() => setTranscriptModalOpen(true)}
412
- onRegenerateCaptions={onRegenerateCaptions}
413
- selectedCaptionId={selectedCaptionId}
414
- onCaptionSegmentChange={onCaptionSegmentChange}
415
- />
1051
+ {/* ── Fade-shape picker — right-click a fade grip (see
1052
+ `TimelineCanvas`'s `contextMenu` handler). `fixed`-positioned at the
1053
+ CLIENT coordinates the grip was clicked at, since it renders outside
1054
+ the canvas surface's own coordinate space. A full-surface backdrop
1055
+ behind it closes the menu on any click (or right-click) elsewhere —
1056
+ the same dismiss-on-outside-interaction every other popover here
1057
+ uses (TrackSettingsPopover). Options are ICONS (`FadeCurveIcon`),
1058
+ not text — Vegas shows the curve's shape, not its name — with the
1059
+ shape name kept as `title`/`aria-label` so it's still discoverable
1060
+ on hover and to a screen reader. */}
1061
+ {fadeCurveMenu && (() => {
1062
+ const track = project.audio?.tracks?.find(t => t.id === fadeCurveMenu.trackId)
1063
+ const activeCurve: FadeCurve = (fadeCurveMenu.side === 'in' ? track?.fadeInCurve : track?.fadeOutCurve) ?? DEFAULT_FADE_CURVE
1064
+ return (
1065
+ <>
1066
+ <div
1067
+ data-timeline-chrome
1068
+ data-testid="fade-curve-menu-backdrop"
1069
+ className="fixed inset-0 z-40"
1070
+ onClick={() => setFadeCurveMenu(null)}
1071
+ onContextMenu={(e) => { e.preventDefault(); setFadeCurveMenu(null) }}
1072
+ />
1073
+ <div
1074
+ data-timeline-chrome
1075
+ /* Written per mode rather than through `--editor-*` tokens: the
1076
+ dark branch is the exact class list this menu has always had,
1077
+ so a light theme gains a light popover without the dark one
1078
+ shifting by a single hairline. */
1079
+ className={`fixed z-50 flex gap-1 rounded border p-1 shadow-xl ${mode === 'light' ? 'border-gray-300 bg-white' : 'border-gray-700 bg-gray-900'}`}
1080
+ style={{ left: fadeCurveMenu.x, top: fadeCurveMenu.y }}
1081
+ >
1082
+ {([
1083
+ ['linear', 'Linear'],
1084
+ ['log', 'Logarithmic'],
1085
+ ['exp', 'Exponential'],
1086
+ ] as const).map(([curve, label]) => (
1087
+ <button
1088
+ key={curve}
1089
+ type="button"
1090
+ title={label}
1091
+ aria-label={label}
1092
+ aria-pressed={activeCurve === curve}
1093
+ className={`rounded p-0.5 transition-colors ${
1094
+ activeCurve === curve
1095
+ ? (mode === 'light' ? 'bg-gray-200 ring-1 ring-emerald-600/60' : 'bg-gray-700 ring-1 ring-emerald-400/60')
1096
+ : (mode === 'light' ? 'hover:bg-gray-100' : 'hover:bg-gray-800')
1097
+ }`}
1098
+ onClick={() => handleSetFadeCurve(fadeCurveMenu.trackId, fadeCurveMenu.side, curve)}
1099
+ >
1100
+ <FadeCurveIcon curve={curve} active={activeCurve === curve} mode={mode} />
1101
+ </button>
1102
+ ))}
1103
+ </div>
1104
+ </>
1105
+ )
1106
+ })()}
416
1107
 
417
- {/* ── Transcript modal ── */}
418
- {transcriptModalOpen && (
419
- <TranscriptModalWithClock
420
- clock={clock}
421
- project={project}
422
- captionTrack={captionTrack}
423
- onCaptionEdit={onCaptionEdit}
424
- onClose={() => setTranscriptModalOpen(false)}
425
- />
426
- )}
1108
+ {/* ── Keyframe-strip popup (SP9b T3.3) — right-click a keyframe diamond
1109
+ (see `TimelineCanvas`'s `contextMenu` handler). The
1110
+ fade-shape picker's exact sibling: same `fixed`-positioned menu at
1111
+ the CLIENT coordinates the diamond was clicked at, same full-surface
1112
+ dismiss-on-outside-interaction backdrop. Two sections: the six
1113
+ EASING_NAMES (labelled "Easing to next keyframe" — easing is
1114
+ OUTGOING, see `Keyframe.easing`'s doc in schema.ts, so this is the
1115
+ one place that direction has to be said out loud or a user authors
1116
+ the wrong curve) and, always available regardless of `isLast`, the
1117
+ keyframe's own removal. */}
1118
+ {keyframeMenu && (() => {
1119
+ const item = trackItems(project).flat().find(i => i.id === keyframeMenu.itemId)
1120
+ const easingAt = (prop: KeyframeProp): EasingName => {
1121
+ const track = item?.keyframes?.find(kt => kt.prop === prop)
1122
+ return track?.points.find(p => p.t === keyframeMenu.t)?.easing ?? 'linear'
1123
+ }
1124
+ const easings = keyframeMenu.props.map(easingAt)
1125
+ const activeEasing: EasingName | null = easings.length > 0 && easings.every(e => e === easings[0]) ? easings[0] : null
1126
+ return (
1127
+ <>
1128
+ <div
1129
+ data-timeline-chrome
1130
+ data-testid="keyframe-menu-backdrop"
1131
+ className="fixed inset-0 z-40"
1132
+ onClick={() => setKeyframeMenu(null)}
1133
+ onContextMenu={(e) => { e.preventDefault(); setKeyframeMenu(null) }}
1134
+ />
1135
+ <div
1136
+ data-timeline-chrome
1137
+ /* `fadeCurveMenu`'s exact sibling (see that menu's own comment
1138
+ below) — mode-conditional per class rather than through
1139
+ `--editor-*` tokens, so the dark branch is the exact class
1140
+ list this menu has always had and a light theme gains a
1141
+ light popover without the dark one shifting by a hairline. */
1142
+ className={`fixed z-50 flex flex-col gap-1.5 rounded border p-2 shadow-xl ${mode === 'light' ? 'border-gray-300 bg-white' : 'border-gray-700 bg-gray-900'}`}
1143
+ style={{ left: keyframeMenu.x, top: keyframeMenu.y }}
1144
+ >
1145
+ <div className={`px-0.5 text-[10px] ${mode === 'light' ? 'text-gray-600' : 'text-gray-500'}`}>Easing to next keyframe</div>
1146
+ <div className="flex flex-wrap gap-1 max-w-[220px]">
1147
+ {EASING_NAMES.map((name) => (
1148
+ <button
1149
+ key={name}
1150
+ type="button"
1151
+ title={keyframeMenu.isLast ? 'No next keyframe' : EASING_LABELS[name]}
1152
+ aria-label={EASING_LABELS[name]}
1153
+ aria-pressed={activeEasing === name}
1154
+ disabled={keyframeMenu.isLast}
1155
+ className={`rounded px-1.5 py-0.5 text-[11px] transition-colors ${
1156
+ keyframeMenu.isLast
1157
+ ? (mode === 'light' ? 'text-gray-400 cursor-not-allowed' : 'text-gray-600 cursor-not-allowed')
1158
+ : activeEasing === name
1159
+ ? (mode === 'light' ? 'bg-gray-200 text-gray-900 ring-1 ring-emerald-600/60' : 'bg-gray-700 text-gray-100 ring-1 ring-emerald-400/60')
1160
+ : (mode === 'light' ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-800')
1161
+ }`}
1162
+ onClick={() => handleSetKeyframeEasing(keyframeMenu.itemId, keyframeMenu.t, keyframeMenu.props, name)}
1163
+ >
1164
+ {EASING_LABELS[name]}
1165
+ </button>
1166
+ ))}
1167
+ </div>
1168
+ <div className={`h-px ${mode === 'light' ? 'bg-gray-200' : 'bg-gray-700'}`} />
1169
+ <button
1170
+ type="button"
1171
+ className={`rounded px-1.5 py-1 text-left text-[11px] hover:bg-red-500/10 ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}
1172
+ onClick={() => handleRemoveKeyframe(keyframeMenu.itemId, keyframeMenu.t)}
1173
+ >
1174
+ Remove keyframe
1175
+ </button>
1176
+ </div>
1177
+ </>
1178
+ )
1179
+ })()}
427
1180
 
428
1181
  </div>
429
1182
  </TimelineContext.Provider>
430
1183
  )
431
1184
  }
432
-
433
- // The transcript views highlight the active caption segment, so they genuinely
434
- // display time and must re-render every tick. Subscribing HERE (rather than in
435
- // Timeline) keeps the per-tick re-render scoped to these leaves — Timeline and
436
- // the track rows are unaffected.
437
- function TranscriptPanelWithClock({
438
- clock,
439
- ...rest
440
- }: { clock: PlaybackClock } & Omit<ComponentProps<typeof TranscriptPanel>, 'currentTime'>) {
441
- const currentTime = usePlaybackTime(clock)
442
- return <TranscriptPanel currentTime={currentTime} {...rest} />
443
- }
444
-
445
- function TranscriptModalWithClock({
446
- clock,
447
- ...rest
448
- }: { clock: PlaybackClock } & Omit<ComponentProps<typeof TranscriptModal>, 'currentTime'>) {
449
- const currentTime = usePlaybackTime(clock)
450
- return <TranscriptModal currentTime={currentTime} {...rest} />
451
- }