@bycrux/editor 0.11.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,977 @@
1
+ import { useRef, type ComponentType, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
2
+ import {
3
+ AlignCenterHorizontal,
4
+ AlignCenterVertical,
5
+ AlignEndHorizontal,
6
+ AlignEndVertical,
7
+ AlignStartHorizontal,
8
+ AlignStartVertical,
9
+ ChevronLeft,
10
+ ChevronRight,
11
+ Diamond,
12
+ RotateCcw,
13
+ } from 'lucide-react'
14
+ import type { KeyframeProp, VisualItem } from '../schema'
15
+ import {
16
+ canKeyframe,
17
+ disableKeyframing,
18
+ enableKeyframing,
19
+ hasKeyframes,
20
+ isUniformScale,
21
+ removeKeyframe,
22
+ setKeyframe,
23
+ trackFor,
24
+ transformProps,
25
+ valueAt,
26
+ canKeyframeProp,
27
+ } from './keyframeOps'
28
+ import { usePlaybackTime, type PlaybackClock } from './playback-clock'
29
+ import { NumberField, Slider, cn, stepValue } from '../ui'
30
+
31
+ /**
32
+ * `<OverlayInspector>` — the editor's contextual right-hand **Transform**
33
+ * properties panel for the selected overlay. One section holding the
34
+ * keyframeable transform props as a scale slider + percent box, a
35
+ * position pair, a rotation box with a circular dial, an opacity box, and a
36
+ * six-button align row — plus a per-property keyframe unit (`‹ ◇ ›`) and an
37
+ * all-properties one in the section header.
38
+ *
39
+ * WHICH props those are depends on the item: an overlay scaled uniformly
40
+ * carries `scale`, one scaled per-axis carries `scaleX`/`scaleY` instead, and
41
+ * the Uniform-scale toggle moves an item between the two. See `uniform` and
42
+ * `allProps` below — that split runs through the header actions as well as
43
+ * the Scale row itself.
44
+ *
45
+ * Every control obeys the SAME CapCut-style auto-keyframe rule, factored into
46
+ * {@link writeProp}: editing a prop that's already keyframed drops a keyframe
47
+ * at the playhead instead of overwriting the static scalar; editing an
48
+ * unkeyframed prop writes the scalar. Continuous gestures (typing, slider
49
+ * drag, dial drag) preview per change and commit once when the gesture ends;
50
+ * discrete actions (steppers, align, reset, diamonds) fire one `onChange`.
51
+ *
52
+ * Deliberately the ONLY place opacity is editable at all — the preview's drag
53
+ * gestures (useDragOverlay) cover offset/scale/rotation but never opacity, so
54
+ * without this panel opacity could never be keyframed either.
55
+ *
56
+ * Props-driven and host-agnostic, like the rest of this package: it never
57
+ * reaches into a store. All persistence flows back through the three
58
+ * callbacks, which the host (VideoEditor) wires to its own sync core.
59
+ */
60
+ export interface OverlayInspectorProps {
61
+ /** The single selected item, or null. Non-overlay items and null both
62
+ * render the empty state — the component enforces its own "overlay only"
63
+ * contract rather than trusting the caller to have already filtered. */
64
+ item: VisualItem | null
65
+ /** The shared playback clock. Subscribed HERE (usePlaybackTime), not by the
66
+ * caller — so a ~60Hz playhead tick re-renders only this small panel
67
+ * instead of the whole editor (see playback-clock.ts's doc comment). */
68
+ clock: PlaybackClock
69
+ /** Live-preview a continuously-typed edit: no undo entry, no save yet.
70
+ * The same contract the Content tab's fields use (OverlayContentPanel) —
71
+ * both routed through VideoEditor's previewOverlayProps. */
72
+ onPreview: (item: VisualItem) => void
73
+ /** Commit the last previewed edit as one undo step + queued save. Fired on
74
+ * the number input's blur, closing the typing gesture. */
75
+ onCommit: () => void
76
+ /** A discrete, already-final edit — the keyframe diamond toggle. Persisted
77
+ * immediately; there is no separate blur to commit on. */
78
+ onChange: (item: VisualItem) => void
79
+ /** Move the playhead to an absolute timeline time. Used only by the keyframe
80
+ * navigation arrows. Optional: a host that hasn't wired seeking yet gets the
81
+ * arrows rendered but disabled, rather than arrows that silently do nothing. */
82
+ onSeek?: (time: number) => void
83
+ }
84
+
85
+ interface RowSpec {
86
+ prop: KeyframeProp
87
+ /** The control's ACCESSIBLE name (`aria-label` on the number box, and the
88
+ * `${name}` in every diamond/arrow/stepper label built from this row).
89
+ * Deliberately separate from the VISIBLE label, which the redesign changed
90
+ * ("Offset X" is now shown as a `Position` row with an `X` prefix): the
91
+ * accessible names are the panel's behavioural contract and must not
92
+ * drift, so the visible chrome can be restyled freely without touching
93
+ * what assistive tech — or the behaviour tests — see. */
94
+ name: string
95
+ step: number
96
+ min?: number
97
+ max?: number
98
+ }
99
+
100
+ // Offsets/rotation are unbounded (percent of frame / degrees, can go negative
101
+ // or past 100/360 for effects); scale only needs a floor above zero; opacity
102
+ // is clamped to the 0–1 range the renderer expects.
103
+ const ROWS: Record<KeyframeProp, RowSpec> = {
104
+ offsetX: { prop: 'offsetX', name: 'Offset X', step: 1 },
105
+ offsetY: { prop: 'offsetY', name: 'Offset Y', step: 1 },
106
+ scale: { prop: 'scale', name: 'Scale', step: 0.01, min: 0.01 },
107
+ scaleX: { prop: 'scaleX', name: 'Scale X', step: 0.01, min: 0.01 },
108
+ scaleY: { prop: 'scaleY', name: 'Scale Y', step: 0.01, min: 0.01 },
109
+ rotation: { prop: 'rotation', name: 'Rotation', step: 1 },
110
+ opacity: { prop: 'opacity', name: 'Opacity', step: 0.01, min: 0, max: 1 },
111
+ }
112
+
113
+ /** Identity transform. Kept in step with `geometryAt`'s defaults in
114
+ * `@bycrux/timeline-core` — the same values `valueAt` falls back to when a
115
+ * prop has neither a track nor a static scalar. */
116
+ const DEFAULTS: Record<KeyframeProp, number> = {
117
+ offsetX: 0,
118
+ offsetY: 0,
119
+ scale: 1,
120
+ scaleX: 1,
121
+ scaleY: 1,
122
+ rotation: 0,
123
+ opacity: 1,
124
+ }
125
+
126
+ /** Range the scale SLIDER spans. Wider than a sane layout needs on both ends
127
+ * so the handle never pins at an edge mid-animation; the number boxes stay
128
+ * the way to reach a value outside it (they only carry ROWS.scale's floor). */
129
+ const SCALE_SLIDER_MIN = 0.05
130
+ const SCALE_SLIDER_MAX = 4
131
+
132
+ /**
133
+ * The single write rule this whole panel obeys. Auto-keyframe: a prop that is
134
+ * already animated gets a NEW keyframe at the playhead, so editing it mid-
135
+ * animation refines the curve instead of silently detaching the value from
136
+ * it; a prop that isn't animated just takes the static scalar.
137
+ *
138
+ * Every control routes through here — typing, slider, dial, steppers, align,
139
+ * reset — so there is exactly one place that decision is made and none of
140
+ * them can drift from the others.
141
+ */
142
+ function writeProp(item: VisualItem, prop: KeyframeProp, localT: number, value: number): VisualItem {
143
+ return hasKeyframes(item, prop)
144
+ ? setKeyframe(item, prop, localT, value)
145
+ : { ...item, [prop]: value }
146
+ }
147
+
148
+ /**
149
+ * Remove one per-axis scale from `item` ENTIRELY — the static scalar AND any
150
+ * keyframe track — so the resolver falls back to uniform `scale` again.
151
+ *
152
+ * The one write in this panel that does NOT go through {@link writeProp}, and
153
+ * it cannot: `writeProp` writes VALUES, and no value of `scaleX` means
154
+ * "absent" — 1 still shadows a `scale` of 1.2. `disableKeyframing` is no help
155
+ * either despite dropping the track, because its whole job is to write the
156
+ * sampled value into the static scalar so nothing jumps, and that scalar is
157
+ * exactly the field that has to go.
158
+ *
159
+ * Peeling the points off with `removeKeyframe` IS the documented way to drop a
160
+ * track without that scalar write: its last-point branch routes through
161
+ * `withTrack`, which removes the track (and `item.keyframes` itself when it
162
+ * was the last one). The `delete` then clears the scalar off a COPY — the
163
+ * input item is never mutated, matching keyframeOps' contract.
164
+ *
165
+ * Returns the same item when there was nothing to clear, so reference equality
166
+ * still tells a caller a no-op happened.
167
+ */
168
+ function clearScaleAxis(item: VisualItem, prop: 'scaleX' | 'scaleY'): VisualItem {
169
+ let next = item
170
+ // Times read off the ORIGINAL track, which `removeKeyframe` never mutates;
171
+ // each `t` is distinct (normalizeTrack's invariant) so one pass clears all.
172
+ for (const point of trackFor(item, prop)?.points ?? []) next = removeKeyframe(next, prop, point.t)
173
+ if (next[prop] === undefined) return next
174
+ const stripped = { ...next }
175
+ delete stripped[prop]
176
+ return stripped
177
+ }
178
+
179
+ /** The offset (percent of frame) that puts the overlay's edge on the frame's
180
+ * edge for `edge` = -1 (left/top), 0 (center/middle), +1 (right/bottom).
181
+ * Mirrors useDragOverlay's edge-snap: an overlay at scale >= 1 already covers
182
+ * the frame, so it has no edge to align to and every alignment collapses to
183
+ * centered rather than pushing it further off-frame.
184
+ *
185
+ * PER-AXIS by the caller, not by this function: `scale` is whichever axis'
186
+ * scale the alignment being computed runs along — `scaleX` for the
187
+ * left/center/right buttons (which write `offsetX`, a percent of frame
188
+ * WIDTH), `scaleY` for top/middle/bottom. Keeping the signature one scalar
189
+ * rather than taking the whole geometry keeps this a self-contained bit of
190
+ * arithmetic the tests can drive directly; the axis choice lives at the one
191
+ * call site, in the align button map. */
192
+ export function alignedOffset(scale: number, edge: -1 | 0 | 1): number {
193
+ const magnitude = Math.max(0, (0.5 - scale / 2) * 100)
194
+ // The `+ 0` normalizes the NEGATIVE zero `-1 * 0` produces at scale >= 1.
195
+ // It renders and serializes identically to 0, but it is not `Object.is`-
196
+ // equal to it, so an item carrying `offsetX: -0` compares unequal to an
197
+ // otherwise-identical one in any structural diff (version compare, undo
198
+ // dedupe) that reaches for strict equality.
199
+ return edge * magnitude + 0
200
+ }
201
+
202
+ // Curve-sampled values can carry float noise (e.g. 29.999999999999996) picked
203
+ // up from interpolation. Rounds only what's DISPLAYED — a typed edit is parsed
204
+ // from the operator's own text (NumberField), never from this rounded number.
205
+ // The steppers round their RESULT to the same 2dp, in `stepValue`: `1.2 + 0.01`
206
+ // lands on 1.2100000000000002 otherwise, and a stepper is supposed to be exact.
207
+ function displayValue(value: number): number {
208
+ return Math.round(value * 100) / 100
209
+ }
210
+
211
+ /** Normalize degrees into `[0, 360)`. Matches useDragOverlay's rotate handler
212
+ * so the dial and the preview's rotate handle agree on what "45 degrees" is. */
213
+ function normalizeDegrees(deg: number): number {
214
+ return ((deg % 360) + 360) % 360
215
+ }
216
+
217
+ // ── Shared chrome ────────────────────────────────────────────────────────
218
+
219
+ const ROW_LABEL_CLASS = 'w-14 shrink-0 text-[11px] text-[var(--editor-text)]/55'
220
+ const ICON_BUTTON_CLASS =
221
+ 'flex h-5 w-5 shrink-0 items-center justify-center rounded text-[var(--editor-text)]/45 transition-colors hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)] disabled:pointer-events-none disabled:opacity-25'
222
+
223
+ function IconButton({
224
+ label,
225
+ disabled,
226
+ onClick,
227
+ title,
228
+ children,
229
+ }: {
230
+ label: string
231
+ disabled?: boolean
232
+ onClick: () => void
233
+ title?: string
234
+ children: ReactNode
235
+ }) {
236
+ return (
237
+ <button type="button" aria-label={label} title={title} disabled={disabled} onClick={onClick} className={ICON_BUTTON_CLASS}>
238
+ {children}
239
+ </button>
240
+ )
241
+ }
242
+
243
+ interface KeyframeNavProps {
244
+ prevLabel: string
245
+ nextLabel: string
246
+ diamondLabel: string
247
+ pressed: boolean
248
+ canPrev: boolean
249
+ canNext: boolean
250
+ onPrev: () => void
251
+ onNext: () => void
252
+ onDiamond: () => void
253
+ /** Greys the diamond out — the property exists but cannot be keyframed here. */
254
+ diamondDisabled?: boolean
255
+ /** Tooltip explaining WHY, shown only when disabled. */
256
+ diamondReason?: string
257
+ }
258
+
259
+ /** The `‹ ◇ ›` unit: step to the previous/next keyframe, and toggle the
260
+ * keyframe at the playhead. One component so every row — and the header's
261
+ * all-props variant — lays out and labels the three controls identically. */
262
+ function KeyframeNav({
263
+ prevLabel, nextLabel, diamondLabel, pressed, canPrev, canNext, onPrev, onNext, onDiamond,
264
+ diamondDisabled = false, diamondReason,
265
+ }: KeyframeNavProps) {
266
+ return (
267
+ <div className="flex shrink-0 items-center gap-0.5">
268
+ <IconButton label={prevLabel} disabled={!canPrev} onClick={onPrev}>
269
+ <ChevronLeft size={11} />
270
+ </IconButton>
271
+ <button
272
+ type="button"
273
+ aria-label={diamondLabel}
274
+ aria-pressed={pressed}
275
+ aria-disabled={diamondDisabled || undefined}
276
+ disabled={diamondDisabled}
277
+ title={diamondReason}
278
+ onClick={onDiamond}
279
+ className={cn(
280
+ 'flex h-5 w-5 shrink-0 items-center justify-center rounded transition-colors',
281
+ // DISABLED, not hidden. A control that vanishes reads as a bug and
282
+ // invites a support question; one that is visibly unavailable and
283
+ // says why reads as a limitation, which is what it is.
284
+ diamondDisabled
285
+ ? 'cursor-not-allowed text-[var(--editor-text)]/20'
286
+ : pressed
287
+ ? 'text-[var(--editor-accent)]'
288
+ : 'text-[var(--editor-text)]/40 hover:text-[var(--editor-text)]/70',
289
+ )}
290
+ >
291
+ <Diamond size={11} fill={pressed ? 'currentColor' : 'none'} />
292
+ </button>
293
+ <IconButton label={nextLabel} disabled={!canNext} onClick={onNext}>
294
+ <ChevronRight size={11} />
295
+ </IconButton>
296
+ </div>
297
+ )
298
+ }
299
+
300
+ const DIAL_SIZE_PX = 40
301
+ const DIAL_CENTER = DIAL_SIZE_PX / 2
302
+ const DIAL_RADIUS = 14
303
+
304
+ /**
305
+ * A circular rotation dial. 0 degrees points UP and positive is clockwise,
306
+ * matching `rotation`'s "degrees, clockwise" schema comment and the preview's
307
+ * rotate handle.
308
+ *
309
+ * Pointer drags use `setPointerCapture` so a drag that leaves the little
310
+ * 40px box still tracks. Keyboard is the accessible equivalent: arrows nudge
311
+ * 1 degree, Shift+arrow 15.
312
+ */
313
+ function RotationDial({
314
+ rotation,
315
+ onPreview,
316
+ onCommit,
317
+ onChange,
318
+ }: {
319
+ rotation: number
320
+ onPreview: (deg: number) => void
321
+ onCommit: () => void
322
+ onChange: (deg: number) => void
323
+ }) {
324
+ const ref = useRef<SVGSVGElement>(null)
325
+ const dragging = useRef(false)
326
+ const previewed = useRef(false)
327
+
328
+ /** Degrees clockwise-from-up under the pointer, or null when the geometry
329
+ * can't support an angle. jsdom (and any `display:none` ancestor) hands
330
+ * back a 0x0 rect, and a pointer exactly on the centre has no direction —
331
+ * both bail rather than emitting a bogus angle. `setKeyframe`'s
332
+ * `Number.isFinite` guard would catch a NaN, but relying on a downstream
333
+ * guard to clean up after this one is how a NaN reaches the scalar path,
334
+ * which has no guard at all. */
335
+ function angleFrom(e: ReactPointerEvent): number | null {
336
+ const rect = ref.current?.getBoundingClientRect()
337
+ if (!rect || rect.width === 0 || rect.height === 0) return null
338
+ const dx = e.clientX - (rect.left + rect.width / 2)
339
+ const dy = e.clientY - (rect.top + rect.height / 2)
340
+ if (dx === 0 && dy === 0) return null
341
+ const deg = Math.atan2(dx, -dy) * (180 / Math.PI)
342
+ if (!Number.isFinite(deg)) return null
343
+ return normalizeDegrees(deg)
344
+ }
345
+
346
+ function onPointerDown(e: ReactPointerEvent<SVGSVGElement>) {
347
+ dragging.current = true
348
+ previewed.current = false
349
+ e.currentTarget.setPointerCapture?.(e.pointerId)
350
+ const deg = angleFrom(e)
351
+ if (deg === null) return
352
+ previewed.current = true
353
+ onPreview(deg)
354
+ }
355
+
356
+ function onPointerMove(e: ReactPointerEvent<SVGSVGElement>) {
357
+ if (!dragging.current) return
358
+ const deg = angleFrom(e)
359
+ if (deg === null) return
360
+ previewed.current = true
361
+ onPreview(deg)
362
+ }
363
+
364
+ function onPointerUp(e: ReactPointerEvent<SVGSVGElement>) {
365
+ if (!dragging.current) return
366
+ dragging.current = false
367
+ e.currentTarget.releasePointerCapture?.(e.pointerId)
368
+ // Only close a gesture that actually previewed something — a click that
369
+ // never produced an angle must not spend an undo entry on nothing.
370
+ if (!previewed.current) return
371
+ previewed.current = false
372
+ onCommit()
373
+ }
374
+
375
+ function onKeyDown(e: ReactKeyboardEvent<SVGSVGElement>) {
376
+ const step = e.shiftKey ? 15 : 1
377
+ let delta = 0
378
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') delta = -step
379
+ else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') delta = step
380
+ else return
381
+ e.preventDefault()
382
+ onChange(normalizeDegrees(rotation + delta))
383
+ }
384
+
385
+ // -90 puts 0 degrees at the top of the SVG; +sin/+cos then sweeps clockwise.
386
+ const rad = ((rotation - 90) * Math.PI) / 180
387
+ const handleX = DIAL_CENTER + Math.cos(rad) * DIAL_RADIUS
388
+ const handleY = DIAL_CENTER + Math.sin(rad) * DIAL_RADIUS
389
+
390
+ return (
391
+ <svg
392
+ ref={ref}
393
+ width={DIAL_SIZE_PX}
394
+ height={DIAL_SIZE_PX}
395
+ viewBox={`0 0 ${DIAL_SIZE_PX} ${DIAL_SIZE_PX}`}
396
+ role="slider"
397
+ aria-label="Rotation dial"
398
+ aria-valuemin={0}
399
+ aria-valuemax={360}
400
+ aria-valuenow={Math.round(rotation)}
401
+ tabIndex={0}
402
+ onPointerDown={onPointerDown}
403
+ onPointerMove={onPointerMove}
404
+ onPointerUp={onPointerUp}
405
+ onKeyDown={onKeyDown}
406
+ className="shrink-0 cursor-grab touch-none select-none rounded-full outline-none focus-visible:ring-1 focus-visible:ring-[var(--editor-accent)] active:cursor-grabbing"
407
+ >
408
+ <circle
409
+ cx={DIAL_CENTER}
410
+ cy={DIAL_CENTER}
411
+ r={DIAL_RADIUS + 2}
412
+ fill="var(--editor-surface)"
413
+ stroke="var(--editor-border)"
414
+ strokeWidth={1}
415
+ />
416
+ <line
417
+ x1={DIAL_CENTER}
418
+ y1={DIAL_CENTER}
419
+ x2={handleX}
420
+ y2={handleY}
421
+ stroke="var(--editor-accent)"
422
+ strokeWidth={1.5}
423
+ strokeLinecap="round"
424
+ />
425
+ <circle cx={handleX} cy={handleY} r={2.5} fill="var(--editor-accent)" />
426
+ </svg>
427
+ )
428
+ }
429
+
430
+ interface NumberCellProps {
431
+ row: RowSpec
432
+ /** Accessible-name override for the second box of a paired row (Scale Y). */
433
+ name?: string
434
+ /** Visible axis prefix. Purely decorative — the accessible name already
435
+ * carries the axis ("Offset X"), so this is hidden from assistive tech to
436
+ * avoid reading it twice. */
437
+ prefix?: string
438
+ value: number
439
+ onPreview: (value: number) => void
440
+ onCommit: () => void
441
+ onStep: (direction: 1 | -1) => void
442
+ }
443
+
444
+ /** A number box with its axis prefix and stepper — the panel's repeating unit.
445
+ * A thin binding onto the shared {@link NumberField}: this component's job is
446
+ * only to translate a `RowSpec` into the field's props (and to build the
447
+ * stable per-row `id`), not to own any of the box's behaviour.
448
+ *
449
+ * The `overlay-inspector-${name}` id it builds has no consumer today — no
450
+ * `<label htmlFor>` points at it, no test queries by it, nothing in
451
+ * `montaj_assets/ui` or `montaj_assets/render` reads it. That is deliberate,
452
+ * not dead code to prune: it is a stable, predictable hook for a future
453
+ * bound label, an automation script, or an external test harness to latch
454
+ * onto by row name, laid down now so it doesn't need inventing later. */
455
+ function NumberCell({ row, name = row.name, prefix, value, onPreview, onCommit, onStep }: NumberCellProps) {
456
+ return (
457
+ <NumberField
458
+ id={`overlay-inspector-${name.replace(/\s+/g, '-').toLowerCase()}`}
459
+ name={name}
460
+ prefix={prefix}
461
+ step={row.step}
462
+ min={row.min}
463
+ max={row.max}
464
+ value={displayValue(value)}
465
+ onPreview={onPreview}
466
+ onCommit={onCommit}
467
+ onStep={onStep}
468
+ className="h-7 w-16 px-1.5 text-xs"
469
+ />
470
+ )
471
+ }
472
+
473
+ /** A scale number box with its `%` suffix and stepper. Scale is shown as a
474
+ * PERCENTAGE (CapCut: 1 => 100%) while the stored scalar stays a multiplier,
475
+ * so only what is typed and displayed converts — `value` and `onStep` are
476
+ * still multiplier-side, exactly as the slider and `handleStep` always were.
477
+ * One component rather than the inline markup this used to be, because the
478
+ * unlocked Scale X / Scale Y rows have to convert, bound and step identically
479
+ * to the locked `Scale` row; three copies of the `* 100` would drift. */
480
+ function ScalePercentCell({
481
+ row, value, onPreview, onCommit, onStep,
482
+ }: {
483
+ row: RowSpec
484
+ value: number
485
+ onPreview: (value: number) => void
486
+ onCommit: () => void
487
+ onStep: (direction: 1 | -1) => void
488
+ }) {
489
+ return (
490
+ <NumberField
491
+ id={`overlay-inspector-${row.name.replace(/\s+/g, '-').toLowerCase()}`}
492
+ name={row.name}
493
+ // Percent-side bounds, matching the slider's span. Deliberately NOT
494
+ // ROWS.scale*'s multiplier `step`/`min`, which are what the STEPPER
495
+ // works in — hence `onStep` taking a direction rather than this
496
+ // component's own bounds deciding the nudge (see stepValue).
497
+ step={1}
498
+ min={5}
499
+ max={400}
500
+ unit="%"
501
+ value={Math.round(value * 100)}
502
+ onPreview={onPreview}
503
+ onCommit={onCommit}
504
+ onStep={onStep}
505
+ className="h-7 w-12 px-1.5 text-xs"
506
+ />
507
+ )
508
+ }
509
+
510
+ const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
511
+
512
+ export default function OverlayInspector({ item, clock, onPreview, onCommit, onChange, onSeek }: OverlayInspectorProps) {
513
+ // Subscribed unconditionally (hooks can't follow the early return below) —
514
+ // cheap: this component renders nothing else, so a tick that turns out to
515
+ // target a non-overlay/no selection still only re-runs this one function.
516
+ const playhead = usePlaybackTime(clock)
517
+
518
+ if (!canKeyframe(item)) {
519
+ return (
520
+ <div className={SECTION_CLASS}>
521
+ <div className="px-3 py-6 text-center text-[11px] text-[var(--editor-text)]/45">
522
+ Select an overlay to edit its properties.
523
+ </div>
524
+ </div>
525
+ )
526
+ }
527
+
528
+ // A `const` alias so every closure below is narrowed to a non-null overlay
529
+ // without a `!` on each use (TypeScript does not carry a narrowing on a
530
+ // reassignable parameter into a nested function).
531
+ const target = item
532
+
533
+ // Clamped to the item's own span, matching `applyKeyframeMove`'s clamp in
534
+ // pointer-machine.ts. This panel renders whenever an overlay is SELECTED,
535
+ // and selecting an item does not move the playhead — so a playhead sitting
536
+ // outside the item's [start, end] (e.g. the overlay was selected while
537
+ // parked elsewhere on the timeline) would otherwise hand `setKeyframe`/
538
+ // `enableKeyframing` a negative or over-long `t`, writing a keyframe
539
+ // outside the span every OTHER keyframe consumer (draw, hit-test,
540
+ // keyframeOps) assumes points stay within.
541
+ const localT = Math.min(Math.max(0, playhead - target.start), Math.max(0, target.end - target.start))
542
+
543
+ // Every prop's value at the playhead, sampled ONCE off the incoming item.
544
+ // The header's all-props actions thread five or six writes through one
545
+ // another, so they must read from this snapshot rather than re-sampling a
546
+ // partially written item — otherwise one prop's write could perturb
547
+ // another's value. `scaleX`/`scaleY` are always populated even on a uniform
548
+ // item: `geometryAt` resolves them through `scale`, so they read as the
549
+ // overlay's actual per-axis size whether or not it carries the fields.
550
+ const sampled: Record<KeyframeProp, number> = {
551
+ offsetX: valueAt(target, 'offsetX', localT),
552
+ offsetY: valueAt(target, 'offsetY', localT),
553
+ scale: valueAt(target, 'scale', localT),
554
+ scaleX: valueAt(target, 'scaleX', localT),
555
+ scaleY: valueAt(target, 'scaleY', localT),
556
+ rotation: valueAt(target, 'rotation', localT),
557
+ opacity: valueAt(target, 'opacity', localT),
558
+ }
559
+
560
+ // Which scale the item is actually authored in, and therefore which prop
561
+ // list every all-props action walks. Both are DERIVED from the item on each
562
+ // render, never held in `useState`, like every other value this panel shows:
563
+ // this component is reconciled in place across selection changes, so state
564
+ // would keep answering for the previously selected overlay. The rule itself
565
+ // lives in keyframeOps — the canvas timeline's double-click-to-key gesture
566
+ // needs exactly the same answer, and two copies of it would drift.
567
+ const uniform = isUniformScale(target)
568
+ const allProps = transformProps(target)
569
+
570
+ /** Continuous gesture step — no undo entry yet. */
571
+ function preview(prop: KeyframeProp, value: number) {
572
+ onPreview(writeProp(target, prop, localT, value))
573
+ }
574
+
575
+ /** A discrete, already-final edit — one undo entry, no separate commit. */
576
+ function commitDiscrete(prop: KeyframeProp, value: number) {
577
+ onChange(writeProp(target, prop, localT, value))
578
+ }
579
+
580
+ function handleToggle(prop: KeyframeProp) {
581
+ const next = hasKeyframes(target, prop)
582
+ ? disableKeyframing(target, prop, localT)
583
+ : enableKeyframing(target, prop, localT)
584
+ onChange(next)
585
+ }
586
+
587
+ function handleStep(row: RowSpec, direction: 1 | -1) {
588
+ commitDiscrete(row.prop, stepValue(sampled[row.prop], direction, row))
589
+ }
590
+
591
+ /** Ascending, de-duplicated keyframe times across `props`. */
592
+ function keyframeTimes(props: readonly KeyframeProp[]): number[] {
593
+ const times = new Set<number>()
594
+ for (const prop of props) for (const point of trackFor(target, prop)?.points ?? []) times.add(point.t)
595
+ return [...times].sort((a, b) => a - b)
596
+ }
597
+
598
+ /** Nav-unit wiring for a set of props. The arrows seek the playhead to an
599
+ * ABSOLUTE timeline time (`item.start + t`) — keyframe `t` is item-relative
600
+ * (see Keyframe.t) and the clock is not. Without `onSeek` they render
601
+ * disabled rather than silently doing nothing. */
602
+ function navFor(props: readonly KeyframeProp[]) {
603
+ const times = keyframeTimes(props)
604
+ let prev: number | undefined
605
+ for (const t of times) if (t < localT) prev = t // ascending, so the last one under localT wins
606
+ const next = times.find(t => t > localT)
607
+ return {
608
+ canPrev: !!onSeek && prev !== undefined,
609
+ canNext: !!onSeek && next !== undefined,
610
+ onPrev: () => { if (prev !== undefined) onSeek?.(target.start + prev) },
611
+ onNext: () => { if (next !== undefined) onSeek?.(target.start + next) },
612
+ }
613
+ }
614
+
615
+ /** True when EVERY transform prop has a keyframe at exactly the playhead —
616
+ * what the header diamond reflects, and the branch its click takes. Note
617
+ * this is a stricter test than the per-row diamonds' `hasKeyframes`, which
618
+ * only asks whether the prop is animated at all. */
619
+ const allKeyed = allProps.every(prop => (trackFor(target, prop)?.points ?? []).some(p => p.t === localT))
620
+
621
+ function handleKeyframeAll() {
622
+ let next = target
623
+ if (allKeyed) {
624
+ for (const prop of allProps) {
625
+ const points = trackFor(next, prop)?.points ?? []
626
+ // The distinction matters. `removeKeyframe` on a track's LAST point
627
+ // drops the track WITHOUT writing the sampled value into the static
628
+ // scalar, so the overlay would jump back to whatever stale
629
+ // `item.scale`/`item.rotation`/… was left behind when keyframing was
630
+ // first switched on. `disableKeyframing` writes that value first, so
631
+ // nothing moves. Multi-point tracks keep animating, so they only lose
632
+ // the one point.
633
+ next = points.length > 1
634
+ ? removeKeyframe(next, prop, localT)
635
+ : disableKeyframing(next, prop, localT)
636
+ }
637
+ } else {
638
+ for (const prop of allProps) {
639
+ // `enableKeyframing` is a documented NO-OP on an already-keyframed
640
+ // prop, so it is safe to run across the whole list unconditionally; it
641
+ // only seeds a track for the ones that had none. `setKeyframe` then pins
642
+ // the CURRENT value at the playhead on every prop, animated or not.
643
+ // Values come from `sampled` (read off the original item), so nothing
644
+ // on screen moves and no write perturbs another's sample.
645
+ next = setKeyframe(enableKeyframing(next, prop, localT), prop, localT, sampled[prop])
646
+ }
647
+ }
648
+ onChange(next)
649
+ }
650
+
651
+ // Position (offsetX + offsetY) keyed as a pair — CapCut shows one diamond for
652
+ // Position, so the row animates/toggles both axes together, with the same
653
+ // enable/disable rule the per-row diamonds use (handleToggle). Kept separate
654
+ // from handleKeyframeAll so that the all-props path (`transformProps(item)`,
655
+ // which varies with the uniform-scale lock) is untouched.
656
+ const positionProps: KeyframeProp[] = ['offsetX', 'offsetY']
657
+ const positionKeyed = positionProps.every(prop => hasKeyframes(target, prop))
658
+
659
+ function handlePositionToggle() {
660
+ let next = target
661
+ for (const prop of positionProps) {
662
+ next = positionKeyed ? disableKeyframing(next, prop, localT) : enableKeyframing(next, prop, localT)
663
+ }
664
+ onChange(next)
665
+ }
666
+
667
+ // Scale is shown as a PERCENTAGE (CapCut: 1 => 100%). The box reports percent;
668
+ // the stored scalar stays a multiplier, so convert on the way in. The slider
669
+ // and stepper stay on the multiplier (the Slider below / handleStep). Takes
670
+ // the prop so the unlocked per-axis boxes convert through the same path as
671
+ // the locked one instead of a second copy of the `/ 100`. Unparseable and
672
+ // mid-typing values never reach here — NumberField swallows those.
673
+ function handleScalePercent(prop: 'scale' | 'scaleX' | 'scaleY', pct: number) {
674
+ preview(prop, pct / 100)
675
+ }
676
+
677
+ /**
678
+ * Move the item between uniform `scale` and per-axis `scaleX`/`scaleY`. One
679
+ * discrete edit either way — one undo entry, no separate commit.
680
+ *
681
+ * Both directions route their VALUE write through `writeProp`, so the panel's
682
+ * auto-keyframe rule applies here exactly as it does to typing in a box: a
683
+ * prop that is already animated gets a keyframe at the playhead rather than a
684
+ * silently-detaching static scalar.
685
+ */
686
+ function handleUniformToggle() {
687
+ if (uniform) {
688
+ // ON -> OFF (unlocking). Seed BOTH axes from `sampled.scale` — the scale
689
+ // the overlay actually has at the playhead, animated value included, not
690
+ // the raw `item.scale` — so the instant the lock opens nothing moves.
691
+ // Once `scaleX`/`scaleY` exist they shadow `scale` entirely, which is
692
+ // precisely why they have to be seeded rather than left absent.
693
+ let next = writeProp(target, 'scaleX', localT, sampled.scale)
694
+ next = writeProp(next, 'scaleY', localT, sampled.scale)
695
+ onChange(next)
696
+ return
697
+ }
698
+ // OFF -> ON (locking). Two numbers have to collapse into one and X WINS.
699
+ // That is arbitrary — Y would have been just as defensible — but it is
700
+ // CHOSEN and stable, not accidental: the operator can read the surviving
701
+ // number off the Scale X box before clicking, and locking always means the
702
+ // same thing. Then both per-axis fields are cleared outright, tracks and
703
+ // all (see clearScaleAxis), because merely writing them equal would leave
704
+ // them shadowing `scale` and the row would have nothing to lock TO.
705
+ let next = writeProp(target, 'scale', localT, sampled.scaleX)
706
+ next = clearScaleAxis(next, 'scaleX')
707
+ next = clearScaleAxis(next, 'scaleY')
708
+ onChange(next)
709
+ }
710
+
711
+ function handleReset() {
712
+ let next = target
713
+ // Deliberately does NOT delete keyframe tracks. On a keyframed prop this
714
+ // drops a default-valued keyframe at the playhead — the same
715
+ // non-destructive rule every other control in this panel follows, and it
716
+ // keeps "reset" from silently discarding an animation the operator spent
717
+ // real time on. Clearing a track is the per-row diamond's job.
718
+ // Walks `allProps`, so an unlocked item resets `scaleX`/`scaleY` to 1 and
719
+ // leaves the shadowed `scale` alone. Reset does not re-lock: the lock is
720
+ // the operator's own choice about how this overlay is authored, not a
721
+ // transform value with a default.
722
+ for (const prop of allProps) next = writeProp(next, prop, localT, DEFAULTS[prop])
723
+ onChange(next)
724
+ }
725
+
726
+ const headerNav = navFor(allProps)
727
+ const positionNav = navFor(positionProps)
728
+
729
+ /** The `‹ ◇ ›` unit for one row. */
730
+ function rowNav(prop: KeyframeProp) {
731
+ const row = ROWS[prop]
732
+ const nav = navFor([prop])
733
+ const keyframed = hasKeyframes(target, prop)
734
+ // Per-property, per-kind: a clip animates position/scale/rotation but not
735
+ // opacity, because ffmpeg's `colorchannelmixer aa` takes a <double> and no
736
+ // expression. See canKeyframeProp for the full reason.
737
+ const allowed = canKeyframeProp(target, prop)
738
+ return (
739
+ <KeyframeNav
740
+ prevLabel={`Previous ${row.name} keyframe`}
741
+ nextLabel={`Next ${row.name} keyframe`}
742
+ diamondLabel={keyframed ? `Remove ${row.name} keyframe at playhead` : `Add ${row.name} keyframe at playhead`}
743
+ pressed={keyframed}
744
+ canPrev={nav.canPrev}
745
+ canNext={nav.canNext}
746
+ onPrev={nav.onPrev}
747
+ onNext={nav.onNext}
748
+ onDiamond={() => handleToggle(prop)}
749
+ diamondDisabled={!allowed}
750
+ diamondReason={allowed ? undefined
751
+ : `${row.name} cannot be animated on a video or image clip. The export has no way to vary it over time. Overlays can.`}
752
+ />
753
+ )
754
+ }
755
+
756
+ const alignButtons: { label: string; icon: ComponentType<{ size?: number }>; prop: 'offsetX' | 'offsetY'; edge: -1 | 0 | 1 }[] = [
757
+ { label: 'Align left', icon: AlignStartVertical, prop: 'offsetX', edge: -1 },
758
+ { label: 'Align center', icon: AlignCenterVertical, prop: 'offsetX', edge: 0 },
759
+ { label: 'Align right', icon: AlignEndVertical, prop: 'offsetX', edge: 1 },
760
+ { label: 'Align top', icon: AlignStartHorizontal, prop: 'offsetY', edge: -1 },
761
+ { label: 'Align middle', icon: AlignCenterHorizontal, prop: 'offsetY', edge: 0 },
762
+ { label: 'Align bottom', icon: AlignEndHorizontal, prop: 'offsetY', edge: 1 },
763
+ ]
764
+
765
+ return (
766
+ <div className={SECTION_CLASS}>
767
+ {/* No section TITLE here — the CONTENT/TRANSFORM tab already names this
768
+ pane, so a second "Transform" heading was redundant. This slim bar now
769
+ only hosts the ALL-properties actions (reset-all + keyframe-all); the
770
+ muted "All" scopes them apart from each row's own per-property unit. */}
771
+ <div className="shrink-0 flex items-center gap-1 border-b border-[var(--editor-border)] px-2 py-1.5">
772
+ <span className="px-1 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--editor-text)]/40">
773
+ All
774
+ </span>
775
+ <div className="ml-auto flex items-center gap-1">
776
+ <IconButton label="Reset transform" title="Reset every transform property to its default" onClick={handleReset}>
777
+ <RotateCcw size={12} />
778
+ </IconButton>
779
+ <KeyframeNav
780
+ prevLabel="Previous keyframe"
781
+ nextLabel="Next keyframe"
782
+ diamondLabel="Keyframe all transform properties at playhead"
783
+ pressed={allKeyed}
784
+ canPrev={headerNav.canPrev}
785
+ canNext={headerNav.canNext}
786
+ onPrev={headerNav.onPrev}
787
+ onNext={headerNav.onNext}
788
+ onDiamond={handleKeyframeAll}
789
+ />
790
+ </div>
791
+ </div>
792
+
793
+ <div className="flex flex-col gap-2 p-2">
794
+ {/* ── Scale ─────────────────────────────────────────────────────
795
+ LOCKED (the default, and every legacy overlay): one row (CapCut)
796
+ — slider + a PERCENTAGE value (1 => 100%) + stepper + keyframe
797
+ unit, all writing the uniform `scale`.
798
+
799
+ UNLOCKED: that row splits per axis into Scale X / Scale Y, each
800
+ writing its own prop and carrying its OWN keyframe unit (they
801
+ animate independently, so one shared diamond would be a lie).
802
+ Two rows rather than both boxes crammed into one: the Position
803
+ row already fills the 300px default rail with two boxes and a
804
+ SINGLE shared keyframe unit, and a second unit does not fit
805
+ beside them. Each row is otherwise the Rotate/Opacity row
806
+ verbatim — label, cell, keyframe unit pushed right. The slider
807
+ is dropped while unlocked; it drives one number and there are
808
+ two. */}
809
+ {uniform ? (
810
+ <div className="flex items-center gap-2">
811
+ <span className={ROW_LABEL_CLASS}>Scale</span>
812
+ <Slider
813
+ aria-label="Scale slider"
814
+ value={sampled.scale}
815
+ min={SCALE_SLIDER_MIN}
816
+ max={SCALE_SLIDER_MAX}
817
+ step={0.01}
818
+ onChange={v => preview('scale', v)}
819
+ // Zero-arg: the panel commits whatever it last previewed, so it
820
+ // has no use for the value the Slider offers.
821
+ onCommit={onCommit}
822
+ className="min-w-0 flex-1"
823
+ />
824
+ <ScalePercentCell
825
+ row={ROWS.scale}
826
+ value={sampled.scale}
827
+ onPreview={pct => handleScalePercent('scale', pct)}
828
+ onCommit={onCommit}
829
+ onStep={d => handleStep(ROWS.scale, d)}
830
+ />
831
+ {rowNav('scale')}
832
+ </div>
833
+ ) : (
834
+ (['scaleX', 'scaleY'] as const).map(prop => (
835
+ <div key={prop} className="flex items-center gap-2">
836
+ <span className={ROW_LABEL_CLASS}>{ROWS[prop].name}</span>
837
+ <ScalePercentCell
838
+ row={ROWS[prop]}
839
+ value={sampled[prop]}
840
+ onPreview={pct => handleScalePercent(prop, pct)}
841
+ onCommit={onCommit}
842
+ onStep={d => handleStep(ROWS[prop], d)}
843
+ />
844
+ <div className="ml-auto">{rowNav(prop)}</div>
845
+ </div>
846
+ ))
847
+ )}
848
+
849
+ {/* ── Uniform scale ─────────────────────────────────────────────
850
+ Its own labelled toggle row (CapCut) instead of a bare link icon.
851
+ ON means the item scales through one uniform `scale`; turning it
852
+ off splits that into `scaleX`/`scaleY`, and back on collapses
853
+ them again — see handleUniformToggle for both tiebreaks. The
854
+ state is DERIVED from the item (isUniform), never held here, so
855
+ selecting a different overlay shows that overlay's answer.
856
+ `role="checkbox"` on a <button> is keyboard-operable natively
857
+ (Space/Enter both fire click), so it needs no key handler of its
858
+ own — only a visible focus ring. */}
859
+ <div className="flex items-center gap-2">
860
+ <span className="shrink-0 text-[11px] text-[var(--editor-text)]/55">Uniform scale</span>
861
+ <button
862
+ type="button"
863
+ role="checkbox"
864
+ aria-checked={uniform}
865
+ aria-label="Uniform scale"
866
+ title={uniform ? 'Width and height scale together' : 'Width and height scale independently'}
867
+ onClick={handleUniformToggle}
868
+ className={cn(
869
+ 'ml-auto relative h-4 w-7 shrink-0 rounded-full outline-none transition-colors focus-visible:ring-1 focus-visible:ring-[var(--editor-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--editor-surface)]',
870
+ uniform ? 'bg-[var(--editor-accent)]' : 'bg-[var(--editor-border)]',
871
+ )}
872
+ >
873
+ <span
874
+ className={cn(
875
+ 'absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all',
876
+ uniform ? 'right-0.5' : 'left-0.5',
877
+ )}
878
+ />
879
+ </button>
880
+ </div>
881
+
882
+ {/* ── Position ───────────────────────────────────────────────────
883
+ X and Y with ONE keyframe unit for the pair on the right (CapCut),
884
+ not a diamond crammed in after each axis. */}
885
+ <div className="flex items-center gap-2">
886
+ <span className={ROW_LABEL_CLASS}>Position</span>
887
+ <NumberCell
888
+ row={ROWS.offsetX}
889
+ prefix="X"
890
+ value={sampled.offsetX}
891
+ onPreview={v => preview('offsetX', v)}
892
+ onCommit={onCommit}
893
+ onStep={d => handleStep(ROWS.offsetX, d)}
894
+ />
895
+ <NumberCell
896
+ row={ROWS.offsetY}
897
+ prefix="Y"
898
+ value={sampled.offsetY}
899
+ onPreview={v => preview('offsetY', v)}
900
+ onCommit={onCommit}
901
+ onStep={d => handleStep(ROWS.offsetY, d)}
902
+ />
903
+ <div className="ml-auto">
904
+ <KeyframeNav
905
+ prevLabel="Previous Position keyframe"
906
+ nextLabel="Next Position keyframe"
907
+ diamondLabel={positionKeyed ? 'Remove Position keyframe at playhead' : 'Add Position keyframe at playhead'}
908
+ pressed={positionKeyed}
909
+ canPrev={positionNav.canPrev}
910
+ canNext={positionNav.canNext}
911
+ onPrev={positionNav.onPrev}
912
+ onNext={positionNav.onNext}
913
+ onDiamond={handlePositionToggle}
914
+ />
915
+ </div>
916
+ </div>
917
+
918
+ {/* ── Rotate ───────────────────────────────────────────────────── */}
919
+ <div className="flex items-center gap-2">
920
+ <span className={ROW_LABEL_CLASS}>Rotate</span>
921
+ <NumberCell
922
+ row={ROWS.rotation}
923
+ value={sampled.rotation}
924
+ onPreview={v => preview('rotation', v)}
925
+ onCommit={onCommit}
926
+ onStep={d => handleStep(ROWS.rotation, d)}
927
+ />
928
+ <RotationDial
929
+ rotation={sampled.rotation}
930
+ onPreview={deg => preview('rotation', deg)}
931
+ onCommit={onCommit}
932
+ onChange={deg => commitDiscrete('rotation', deg)}
933
+ />
934
+ <div className="ml-auto">{rowNav('rotation')}</div>
935
+ </div>
936
+
937
+ {/* ── Opacity ──────────────────────────────────────────────────── */}
938
+ <div className="flex items-center gap-2">
939
+ <span className={ROW_LABEL_CLASS}>Opacity</span>
940
+ <NumberCell
941
+ row={ROWS.opacity}
942
+ value={sampled.opacity}
943
+ onPreview={v => preview('opacity', v)}
944
+ onCommit={onCommit}
945
+ onStep={d => handleStep(ROWS.opacity, d)}
946
+ />
947
+ <div className="ml-auto">{rowNav('opacity')}</div>
948
+ </div>
949
+
950
+ {/* ── Align ────────────────────────────────────────────────────── */}
951
+ <div className="flex items-center gap-2">
952
+ <span className={ROW_LABEL_CLASS}>Align</span>
953
+ {[alignButtons.slice(0, 3), alignButtons.slice(3)].map((group, i) => (
954
+ <div key={i} className="flex items-center gap-0.5">
955
+ {group.map(({ label, icon: Icon, prop, edge }) => (
956
+ <IconButton
957
+ key={label}
958
+ label={label}
959
+ // The CURRENT sampled scale, not the raw scalar, so
960
+ // aligning mid-animation snaps to the edge the overlay
961
+ // actually has at the playhead — and the scale of the axis
962
+ // being aligned, since a non-uniform overlay's left edge is
963
+ // set by its WIDTH and its top edge by its HEIGHT. On a
964
+ // uniform item both sample back to `scale`, so legacy
965
+ // overlays align exactly as before.
966
+ onClick={() => commitDiscrete(prop, alignedOffset(prop === 'offsetX' ? sampled.scaleX : sampled.scaleY, edge))}
967
+ >
968
+ <Icon size={12} />
969
+ </IconButton>
970
+ ))}
971
+ </div>
972
+ ))}
973
+ </div>
974
+ </div>
975
+ </div>
976
+ )
977
+ }