@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,384 @@
1
+ import type { EasingName, Keyframe, KeyframeProp, KeyframeTrack, VisualItem } from '../schema'
2
+ import { geometryAt, normalizeTrack } from '@bycrux/timeline-core'
3
+
4
+ /**
5
+ * keyframeOps — the shared, DOM-free keyframe-mutation surface for SP9b.
6
+ *
7
+ * Every export below is a pure function shaped `(item, ...) => VisualItem`:
8
+ * none of them touch React, the DOM, or the app store. Callers (an overlay
9
+ * inspector panel and a canvas timeline keyframe strip, in later phases) are
10
+ * expected to feed the returned item straight into a `sync.mutate` project
11
+ * update. The input `item`, its `keyframes` array, and its point objects are
12
+ * never mutated — every write returns fresh objects/arrays instead.
13
+ *
14
+ * The invariant this module exists to protect: every `KeyframeTrack` it
15
+ * writes has `points` ascending by `t` with no duplicate `t` — the invariant
16
+ * `@bycrux/timeline-core`'s `sampleTrack` assumes and does not itself enforce
17
+ * (see that package's `src/curves.js` module header). Every mutating export
18
+ * below builds its raw (possibly out-of-order, possibly duplicate-`t`) points
19
+ * array and pipes it through `normalizeTrack` before it is ever installed on
20
+ * the returned item — `withTrack`, at the bottom of the "writing" section, is
21
+ * the single place a track is actually written, so that holds by
22
+ * construction rather than by every export remembering to do it.
23
+ *
24
+ * No easing/interpolation math lives here. Curve evaluation stays in
25
+ * `@bycrux/timeline-core` (`sampleTrack`, `geometryAt`) so the preview and
26
+ * the render bake cannot drift from each other or from this module.
27
+ */
28
+
29
+ /**
30
+ * THE single gate on which items support keyframing. Every call site that used
31
+ * to spell `item.type === 'overlay'` inline routes through here instead, so
32
+ * the set of keyframeable kinds is defined in exactly one place.
33
+ *
34
+ * VIDEO, IMAGE AND OVERLAY, since SP9d. It was overlay-only for a real render
35
+ * reason, not a UI preference: the ffmpeg composite emitted ONE static box per
36
+ * segment and had no per-frame hook, while overlays escaped that only because
37
+ * they are captured frame-by-frame in a browser. That changed when
38
+ * `encode-segment.js` learned to compile a curve into a time-varying ffmpeg
39
+ * expression (`animatedGeometry`), so a clip's position, scale and rotation now
40
+ * animate in the export exactly as they do in the preview.
41
+ *
42
+ * Keyframeability is now PER PROPERTY PER KIND, though — a clip can animate
43
+ * position but not opacity — so an item-level yes/no is no longer the whole
44
+ * answer. Use {@link canKeyframeProp} wherever a specific property is in hand;
45
+ * this predicate answers only "can ANY property on this item be keyframed".
46
+ *
47
+ * A type predicate, not a plain `boolean`: call sites used to spell
48
+ * `!item || item.type !== 'overlay'`, which narrowed `item`'s nullability
49
+ * through the guard for free. Returning `item is VisualItem` keeps that
50
+ * narrowing available through this one call instead. It is deliberately
51
+ * `VisualItem`, not some overlay-only subtype — `VisualItem` is a monolithic
52
+ * interface with `type` as a plain field rather than a discriminant, so
53
+ * there is no narrower shape to assert; this is the strongest claim TS can
54
+ * check.
55
+ */
56
+ export function canKeyframe(item: VisualItem | null | undefined): item is VisualItem {
57
+ return !!item && (item.type === 'overlay' || item.type === 'video' || item.type === 'image')
58
+ }
59
+
60
+ /**
61
+ * Whether ONE property on ONE item can be keyframed.
62
+ *
63
+ * Overlays: everything. Clips (video/image): everything EXCEPT `opacity`.
64
+ *
65
+ * The opacity exclusion is a hard limit of the render, not a scope decision.
66
+ * A clip's transform reaches ffmpeg as a filter expression, and ffmpeg happily
67
+ * evaluates expressions for `overlay`'s x/y, `scale`'s w/h and `rotate`'s
68
+ * angle. Its alpha control does not play along: `colorchannelmixer` declares
69
+ * `aa` as a `<double>`, which accepts a literal number and nothing else — no
70
+ * expression, at any evaluation mode. (The `T` flag ffmpeg prints beside it is
71
+ * `AV_OPT_FLAG_RUNTIME_PARAM`, i.e. settable via `sendcmd`/`zmq`; it is not
72
+ * expression support, and it has been misread as such before.) There is
73
+ * therefore no way to fade a clip through the ffmpeg path at all.
74
+ *
75
+ * Overlays are exempt because they never touch that filter: they are baked
76
+ * frame-by-frame in a browser, where opacity is just another CSS value.
77
+ *
78
+ * Closing this gap needs the per-frame browser bake extended to video — decode
79
+ * every frame of the animated span and composite it the way overlays already
80
+ * are. That was measured at 14-33x the expression path's render time and is
81
+ * explicitly out of scope; see docs/RENDER.md.
82
+ */
83
+ export function canKeyframeProp(item: VisualItem | null | undefined, prop: KeyframeProp): boolean {
84
+ if (!canKeyframe(item)) return false
85
+ if (item.type === 'overlay') return true
86
+ return prop !== 'opacity'
87
+ }
88
+
89
+ // ── Reading ──────────────────────────────────────────────────────────────
90
+
91
+ /** The track for `prop` on `item`, or `undefined` if the item isn't
92
+ * keyframed on that prop at all. */
93
+ export function trackFor(item: VisualItem, prop: KeyframeProp): KeyframeTrack | undefined {
94
+ return item.keyframes?.find(track => track.prop === prop)
95
+ }
96
+
97
+ /**
98
+ * True only when `prop` has a track AND that track has at least one point.
99
+ * An empty track never lingers in a well-formed item (see `withTrack`), but
100
+ * this checks the point count anyway rather than assuming that invariant
101
+ * holds for every possible caller or hand-edited project.json.
102
+ */
103
+ export function hasKeyframes(item: VisualItem, prop: KeyframeProp): boolean {
104
+ const track = trackFor(item, prop)
105
+ return !!track && track.points.length > 0
106
+ }
107
+
108
+ /** True when `item` has ANY non-empty keyframe track, on any prop. */
109
+ export function isKeyframed(item: VisualItem): boolean {
110
+ return (item.keyframes ?? []).some(track => track.points.length > 0)
111
+ }
112
+
113
+ /**
114
+ * Whether `item` scales UNIFORMLY — i.e. carries no per-axis scale AT ALL,
115
+ * neither a static `scaleX`/`scaleY` scalar nor a keyframe track for either.
116
+ *
117
+ * ABSENCE is the test, deliberately, and not `scaleX === scaleY`: an overlay
118
+ * the operator unlocked on purpose and happens to have left at 120%/120% is
119
+ * authored per-axis, and an equality test would silently re-lock it the moment
120
+ * the two numbers met.
121
+ */
122
+ export function isUniformScale(item: VisualItem): boolean {
123
+ return (
124
+ item.scaleX === undefined && item.scaleY === undefined &&
125
+ !hasKeyframes(item, 'scaleX') && !hasKeyframes(item, 'scaleY')
126
+ )
127
+ }
128
+
129
+ /** The two orders {@link transformProps} chooses between. Position first,
130
+ * scale, then rotation and opacity — the order the inspector header's
131
+ * all-props actions walk them, kept identical for both shapes. */
132
+ const UNIFORM_TRANSFORM_PROPS: readonly KeyframeProp[] = ['offsetX', 'offsetY', 'scale', 'rotation', 'opacity']
133
+ const PER_AXIS_TRANSFORM_PROPS: readonly KeyframeProp[] = ['offsetX', 'offsetY', 'scaleX', 'scaleY', 'rotation', 'opacity']
134
+
135
+ /**
136
+ * The transform props that are AUTHORITATIVE for `item` — the set that any
137
+ * "do this to EVERY transform prop" action must walk, and the whole reason
138
+ * {@link isUniformScale} exists.
139
+ *
140
+ * Never a flat list of all seven, and never one fixed list of five. The scale
141
+ * props form a fallback chain — `sampleTrack(scaleX) ?? item.scaleX ??
142
+ * <the resolved scale>` (see `geometry.js`'s non-uniform section) — so a
143
+ * per-axis value SHADOWS the uniform one, and getting this set wrong breaks a
144
+ * keyframe-everything action in one of two symmetric ways:
145
+ *
146
+ * - Handing `scaleX`/`scaleY` to a UNIFORM item seeds one-point (i.e.
147
+ * constant) per-axis tracks. Those immediately shadow the `scale` track,
148
+ * and the overlay's uniform zoom silently stops happening — nothing on
149
+ * screen says why, and the damage is invisible until the operator scrubs.
150
+ * - Handing `scale` to a PER-AXIS item writes a prop that `scaleX`/`scaleY`
151
+ * already shadow, so the gesture appears to do nothing at all.
152
+ *
153
+ * Both the inspector's header actions and the canvas timeline's
154
+ * double-click-to-key gesture read this, so the rule is defined once. It used
155
+ * to be a hand-maintained constant in each of them; two copies of a rule whose
156
+ * failure mode is a silent frozen animation is exactly the kind of thing that
157
+ * drifts. Do NOT reintroduce a local copy.
158
+ *
159
+ * The result is ALSO filtered by {@link canKeyframeProp}, which is what keeps a
160
+ * clip's un-animatable `opacity` out of every "do this to every transform prop"
161
+ * action. That matters in both directions and both are easy to get wrong:
162
+ * double-clicking a video would otherwise write an opacity track the renderer
163
+ * silently ignores, and the inspector's header diamond — which lights only when
164
+ * EVERY prop in this list is keyed at the playhead — could then never light on a
165
+ * clip at all, because the one prop it waits for can never be keyed.
166
+ */
167
+ export function transformProps(item: VisualItem): readonly KeyframeProp[] {
168
+ const base = isUniformScale(item) ? UNIFORM_TRANSFORM_PROPS : PER_AXIS_TRANSFORM_PROPS
169
+ return base.filter(prop => canKeyframeProp(item, prop))
170
+ }
171
+
172
+ /**
173
+ * `prop`'s value at item-relative `localT`: the sampled curve when `prop` is
174
+ * keyframed, else the item's static scalar, else the prop's default. This
175
+ * delegates to {@link geometryAt} — the SAME function the preview and the
176
+ * render bake sample from — rather than re-deriving defaults or calling
177
+ * `sampleTrack` directly, so the defaults (scale 1, offsetX/offsetY/rotation
178
+ * 0, opacity 1) live in exactly one place and cannot drift from what
179
+ * actually gets painted. `item.type` is passed through as the `kind` rather
180
+ * than a hardcoded `'overlay'`: `geometryAt`'s `kind` only selects `fit`,
181
+ * which isn't a keyframeable prop, so every one of the five reads is
182
+ * identical either way — but this way the function never lies about what
183
+ * kind of item it's reading.
184
+ */
185
+ export function valueAt(item: VisualItem, prop: KeyframeProp, localT: number): number {
186
+ return geometryAt(item, item.type, localT)[prop]
187
+ }
188
+
189
+ // ── Writing ──────────────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Install `track` as the sole track for `prop` on a NEW item, or remove
193
+ * `prop`'s track entirely when `track` is undefined or empty. This is the
194
+ * single place `item.keyframes` is ever written, so the invariants every
195
+ * mutating export below depends on hold by construction:
196
+ * - removing the last point of a track removes the track;
197
+ * - removing the last track removes `item.keyframes` itself (`undefined`,
198
+ * never a lingering `[]` — downstream code treats "no keyframes" as the
199
+ * static path, and `[]` must behave identically to absent).
200
+ */
201
+ function withTrack(item: VisualItem, prop: KeyframeProp, track: KeyframeTrack | undefined): VisualItem {
202
+ const existing = item.keyframes ?? []
203
+ const idx = existing.findIndex(t => t.prop === prop)
204
+
205
+ if (!track || track.points.length === 0) {
206
+ if (idx < 0) return item // prop already had no track — no-op
207
+ const others = existing.filter(t => t.prop !== prop)
208
+ if (others.length === 0) {
209
+ const next = { ...item }
210
+ delete next.keyframes
211
+ return next
212
+ }
213
+ return { ...item, keyframes: others }
214
+ }
215
+
216
+ const next = idx < 0 ? [...existing, track] : existing.map((t, i) => (i === idx ? track : t))
217
+ return { ...item, keyframes: next }
218
+ }
219
+
220
+ /** Write `value` into `prop`'s own static scalar field on a new item. Used
221
+ * only by `disableKeyframing`, once keyframing is turned off. An exhaustive
222
+ * switch (no `default`) rather than a computed property, so adding a new
223
+ * `KeyframeProp` without a case here is a compile error, not a silent gap. */
224
+ function withStaticValue(item: VisualItem, prop: KeyframeProp, value: number): VisualItem {
225
+ switch (prop) {
226
+ case 'offsetX': return { ...item, offsetX: value }
227
+ case 'offsetY': return { ...item, offsetY: value }
228
+ case 'scale': return { ...item, scale: value }
229
+ case 'scaleX': return { ...item, scaleX: value }
230
+ case 'scaleY': return { ...item, scaleY: value }
231
+ case 'rotation': return { ...item, rotation: value }
232
+ case 'opacity': return { ...item, opacity: value }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Add or replace the keyframe at `t` on `prop`'s track, creating the track
238
+ * if `item` isn't keyframed on `prop` yet. Replacing an existing point at
239
+ * `t` preserves its `easing` unless a new one is passed. Non-finite `t` or
240
+ * `value` are ignored — `item` is returned unchanged rather than writing a
241
+ * malformed point.
242
+ */
243
+ export function setKeyframe(
244
+ item: VisualItem,
245
+ prop: KeyframeProp,
246
+ t: number,
247
+ value: number,
248
+ easing?: EasingName,
249
+ ): VisualItem {
250
+ if (!Number.isFinite(t) || !Number.isFinite(value)) return item
251
+
252
+ const existing = trackFor(item, prop)
253
+ const existingPoint = existing?.points.find(p => p.t === t)
254
+ const resolvedEasing = easing ?? existingPoint?.easing
255
+ const point: Keyframe = resolvedEasing === undefined ? { t, value } : { t, value, easing: resolvedEasing }
256
+
257
+ // Appended, not spliced in place: normalizeTrack's stable sort + last-wins
258
+ // de-duplication is what actually resolves a collision at `t`, so the new
259
+ // point only has to be LAST in authoring order among any duplicates.
260
+ const rawPoints = existing ? [...existing.points, point] : [point]
261
+ return withTrack(item, prop, normalizeTrack({ prop, points: rawPoints }))
262
+ }
263
+
264
+ /**
265
+ * Remove the keyframe at `t` on `prop`'s track. Removing the last point
266
+ * removes the whole track; removing the last track removes `item.keyframes`
267
+ * entirely (see `withTrack`).
268
+ */
269
+ export function removeKeyframe(item: VisualItem, prop: KeyframeProp, t: number): VisualItem {
270
+ const track = trackFor(item, prop)
271
+ if (!track) return item
272
+
273
+ const points = track.points.filter(p => p.t !== t)
274
+ if (points.length === track.points.length) return item // t wasn't present — no-op
275
+ if (points.length === 0) return withTrack(item, prop, undefined)
276
+
277
+ return withTrack(item, prop, normalizeTrack({ prop, points }))
278
+ }
279
+
280
+ /**
281
+ * Remove every keyframe sitting at `t`, across all props — the whole diamond
282
+ * the operator sees, since one diamond on the strip is the UNION of every prop
283
+ * keyed at that instant (`keyframeUnionTimes`).
284
+ *
285
+ * The last-point branch is the reason this exists rather than callers looping
286
+ * `removeKeyframe`. `removeKeyframe` on a track's ONLY point drops the track
287
+ * without writing the sampled value into the item's static scalar, so the
288
+ * overlay snaps back to whatever stale value was sitting there from before
289
+ * keyframing was switched on. `disableKeyframing` samples the curve FIRST and
290
+ * writes it, so nothing moves. Every removal path must take that branch, or
291
+ * the two disagree — which is exactly what happened between the canvas
292
+ * right-click menu and the properties panel before this helper existed.
293
+ *
294
+ * Returns the SAME item when no prop has a point at `t`, so callers can use
295
+ * reference equality to skip a no-op commit.
296
+ */
297
+ export function removeKeyframesAt(item: VisualItem, t: number): VisualItem {
298
+ if (!Number.isFinite(t)) return item
299
+
300
+ const props = (item.keyframes ?? [])
301
+ .filter(track => track.points.some(p => p.t === t))
302
+ .map(track => track.prop)
303
+ if (props.length === 0) return item
304
+
305
+ let next = item
306
+ for (const prop of props) {
307
+ const points = trackFor(next, prop)?.points ?? []
308
+ next = points.length > 1
309
+ ? removeKeyframe(next, prop, t)
310
+ : disableKeyframing(next, prop, t)
311
+ }
312
+ return next
313
+ }
314
+
315
+ /**
316
+ * Retime the keyframe at `fromT` to `toT`, preserving its value and easing.
317
+ * If `toT` collides with an existing keyframe, the MOVED one wins: it is
318
+ * appended after the rest of the points before normalizing, and
319
+ * `normalizeTrack`'s last-wins de-duplication (stable sort, so the later
320
+ * authoring-order entry survives a tie at the same `t`) always keeps the
321
+ * moved point in that case. Non-finite `fromT`/`toT` are ignored.
322
+ */
323
+ export function moveKeyframe(item: VisualItem, prop: KeyframeProp, fromT: number, toT: number): VisualItem {
324
+ if (!Number.isFinite(fromT) || !Number.isFinite(toT)) return item
325
+
326
+ const track = trackFor(item, prop)
327
+ const point = track?.points.find(p => p.t === fromT)
328
+ if (!track || !point) return item
329
+
330
+ const moved: Keyframe = { ...point, t: toT }
331
+ const rest = track.points.filter(p => p.t !== fromT)
332
+ return withTrack(item, prop, normalizeTrack({ prop, points: [...rest, moved] }))
333
+ }
334
+
335
+ /** Set the OUTGOING easing (see `Keyframe.easing`'s doc comment) on the
336
+ * keyframe at `t`. No-op if `prop` has no track or no point at `t`. */
337
+ export function setKeyframeEasing(item: VisualItem, prop: KeyframeProp, t: number, easing: EasingName): VisualItem {
338
+ const track = trackFor(item, prop)
339
+ const point = track?.points.find(p => p.t === t)
340
+ if (!track || !point) return item
341
+
342
+ const points = track.points.map(p => (p.t === t ? { ...p, easing } : p))
343
+ return withTrack(item, prop, normalizeTrack({ prop, points }))
344
+ }
345
+
346
+ /**
347
+ * Turn keyframing ON for `prop`: seed a single keyframe at `atT` whose value
348
+ * is the item's CURRENT value for that prop (via {@link valueAt}), so
349
+ * switching keyframing on never moves the overlay.
350
+ *
351
+ * NO-OP, by construction, when `prop` already has keyframes
352
+ * (`hasKeyframes(item, prop)`): "turn this on" applied to something already
353
+ * on must never destroy the operator's existing animation. A caller with a
354
+ * genuinely destructive intent — discard the current track and start over —
355
+ * expresses that explicitly as `disableKeyframing` followed by
356
+ * `enableKeyframing`, two calls, not a single one that quietly does both. Do
357
+ * NOT "simplify" this back into an unconditional reset: a diamond-toggle UI,
358
+ * a defensive re-render, or a future "enable all props" action can all call
359
+ * this on an already-keyframed prop, and silently replacing a multi-point
360
+ * curve with one seeded point is invisible data loss until the operator
361
+ * scrubs. Non-finite `atT` is also ignored.
362
+ */
363
+ export function enableKeyframing(item: VisualItem, prop: KeyframeProp, atT: number): VisualItem {
364
+ if (!Number.isFinite(atT)) return item
365
+ if (hasKeyframes(item, prop)) return item
366
+
367
+ const value = valueAt(item, prop, atT)
368
+ return withTrack(item, prop, normalizeTrack({ prop, points: [{ t: atT, value }] }))
369
+ }
370
+
371
+ /**
372
+ * Turn keyframing OFF for `prop`: remove its track entirely and write the
373
+ * value the curve held at `atT` into the item's static scalar, so the
374
+ * overlay does not jump the instant keyframing is switched off (the
375
+ * CapCut-style behaviour this is modelled on). The value is read via
376
+ * {@link valueAt} BEFORE the track is removed — `valueAt` needs the track
377
+ * still in place to sample it. Non-finite `atT` is ignored.
378
+ */
379
+ export function disableKeyframing(item: VisualItem, prop: KeyframeProp, atT: number): VisualItem {
380
+ if (!Number.isFinite(atT)) return item
381
+
382
+ const value = valueAt(item, prop, atT)
383
+ return withStaticValue(withTrack(item, prop, undefined), prop, value)
384
+ }
@@ -0,0 +1,146 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ /**
4
+ * The editor's ONE keydown registry — replaces the racing `document`-level
5
+ * listeners that used to live independently in VideoEditor (split, undo/
6
+ * redo) and Timeline (arrows, delete, enter, escape), each with its own
7
+ * slightly different typing-surface guard. `useKeymap` mounts exactly one
8
+ * `document` keydown listener per call site and evaluates bindings in order;
9
+ * the first whose `matches` + `guard` both pass wins.
10
+ *
11
+ * Space is explicitly OUT OF SCOPE — it stays owned by the playback hooks
12
+ * (`useVideoPlayback`/`useEnginePlayback`, both legacy and engine paths) so
13
+ * this registry can never race them. See the hard-coded check in
14
+ * `useKeymap` below.
15
+ *
16
+ * There are two call sites, mirroring where their bindings' dependencies
17
+ * actually live (see T9's report for the full reasoning):
18
+ * - Timeline owns arrows/delete/enter/escape — it mounts its own
19
+ * `useKeymap` with those bindings, in BOTH the pending and review
20
+ * surfaces (exactly where its old internal listener ran).
21
+ * - VideoEditor's ReviewSurface owns split/undo/redo plus every new T9
22
+ * binding (J/K/L shuttle, ripple-delete, command palette) — the
23
+ * editing-only bindings that only ever lived in the review surface.
24
+ */
25
+
26
+ /** INPUT / TEXTAREA / any contentEditable host — every migrated binding's
27
+ * typing-surface guard, ported verbatim from the handlers it replaces.
28
+ * `!!(...)` coerces to a real boolean: `el.isContentEditable` is a plain
29
+ * DOM boolean in every real browser, but partial event objects in tests
30
+ * (and jsdom's incomplete `isContentEditable` support) can leave it
31
+ * `undefined` — this keeps the guard's return type honest either way. */
32
+ export function isTypingTarget(e: KeyboardEvent): boolean {
33
+ const el = e.target as HTMLElement
34
+ return !!(el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
35
+ }
36
+
37
+ export interface KeyBinding {
38
+ id: string
39
+ /** Palette label (also the tooltip/description). */
40
+ description: string
41
+ /** Display-only shortcut hint, e.g. ['⌘', 'Z']. Not used for matching. */
42
+ keyHint?: string[]
43
+ /** Whether this keydown event should fire the binding. */
44
+ matches: (e: KeyboardEvent) => boolean
45
+ /** Extra guard beyond the shared base guard (typing surface + modal-open),
46
+ * e.g. "only when there's a selection" or "only when totalDuration > 0". */
47
+ guard?: () => boolean
48
+ /** Defaults to true. */
49
+ preventDefault?: boolean
50
+ action: (e: KeyboardEvent) => void
51
+ /** Exclude from the command palette — context-only bindings (arrow frame-
52
+ * step, Enter/Escape marker placement) aren't "commands" you run once. */
53
+ paletteHidden?: boolean
54
+ }
55
+
56
+ export interface UseKeymapOptions {
57
+ /** True while any dialog/palette is open — suppresses every binding. */
58
+ modalOpen?: boolean
59
+ }
60
+
61
+ /**
62
+ * Mounts one `document` keydown listener for the given bindings for the
63
+ * lifetime of the component. Bindings and options are read through refs so
64
+ * the listener's identity never churns — callers can pass fresh closures
65
+ * every render without re-registering.
66
+ */
67
+ export function useKeymap(bindings: KeyBinding[], options: UseKeymapOptions = {}) {
68
+ const bindingsRef = useRef(bindings)
69
+ bindingsRef.current = bindings
70
+ const optionsRef = useRef(options)
71
+ optionsRef.current = options
72
+
73
+ useEffect(() => {
74
+ function onKeyDown(e: KeyboardEvent) {
75
+ // Never owned by the keymap — see file header.
76
+ if (e.code === 'Space' || e.key === ' ') return
77
+ if (isTypingTarget(e)) return
78
+ if (optionsRef.current.modalOpen) return
79
+
80
+ for (const binding of bindingsRef.current) {
81
+ if (!binding.matches(e)) continue
82
+ if (binding.guard && !binding.guard()) continue
83
+ if (binding.preventDefault !== false) e.preventDefault()
84
+ binding.action(e)
85
+ return
86
+ }
87
+ }
88
+ document.addEventListener('keydown', onKeyDown)
89
+ return () => document.removeEventListener('keydown', onKeyDown)
90
+ }, [])
91
+ }
92
+
93
+ // ── Combo matchers — small helpers so bindings read declaratively ──────────
94
+
95
+ const mod = (e: KeyboardEvent) => !!(e.metaKey || e.ctrlKey)
96
+
97
+ /** Bare letter key, no modifier check — reproduces the original S-to-split
98
+ * handler's behavior verbatim (it only ever checked `e.key`). */
99
+ export function matchesKey(key: string) {
100
+ const lower = key.toLowerCase()
101
+ return (e: KeyboardEvent) => e.key.toLowerCase() === lower
102
+ }
103
+
104
+ /** Letter key with NO modifier held (mod/alt) — used for the new J/K/L
105
+ * shuttle bindings so they don't fire alongside Cmd/Ctrl/Alt combos. */
106
+ export function matchesPlainKey(key: string) {
107
+ const lower = key.toLowerCase()
108
+ return (e: KeyboardEvent) => e.key.toLowerCase() === lower && !mod(e) && !e.altKey
109
+ }
110
+
111
+ export function matchesUndo(e: KeyboardEvent): boolean {
112
+ return mod(e) && e.key.toLowerCase() === 'z' && !e.shiftKey
113
+ }
114
+
115
+ export function matchesRedo(e: KeyboardEvent): boolean {
116
+ return mod(e) && ((e.key.toLowerCase() === 'z' && e.shiftKey) || e.key.toLowerCase() === 'y')
117
+ }
118
+
119
+ export function matchesModKey(key: string) {
120
+ const lower = key.toLowerCase()
121
+ return (e: KeyboardEvent) => mod(e) && e.key.toLowerCase() === lower
122
+ }
123
+
124
+ /** Mod+Alt+key, e.g. Cmd+Opt+V for paste-attributes. NOTE: `matchesModKey`
125
+ * does NOT exclude `altKey`, so a mod+alt chord matches BOTH this and the
126
+ * bare mod+key binding for the same letter — whichever binding is
127
+ * registered FIRST in a `useKeymap` array wins (first match wins), so a
128
+ * mod+alt binding must always be listed before its mod-only sibling. */
129
+ export function matchesModAltKey(key: string) {
130
+ const lower = key.toLowerCase()
131
+ return (e: KeyboardEvent) => mod(e) && e.altKey && e.key.toLowerCase() === lower
132
+ }
133
+
134
+ /** Delete/Backspace WITHOUT Shift — reserves Shift+Delete for ripple-delete. */
135
+ export function matchesDelete(e: KeyboardEvent): boolean {
136
+ return (e.key === 'Delete' || e.key === 'Backspace') && !e.shiftKey
137
+ }
138
+
139
+ export function matchesShiftDelete(e: KeyboardEvent): boolean {
140
+ return (e.key === 'Delete' || e.key === 'Backspace') && e.shiftKey
141
+ }
142
+
143
+ export const matchesEscape = matchesKey('Escape')
144
+ export const matchesEnter = matchesKey('Enter')
145
+ export const matchesArrowLeft = matchesKey('ArrowLeft')
146
+ export const matchesArrowRight = matchesKey('ArrowRight')