@bycrux/editor 0.12.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +586 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +6 -2
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2202 -251
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,102 @@
1
+ // Source-capped export resolution/fps ceiling for a project. Pure — the export
2
+ // dialog uses this to decide which resolution/fps tiers are worth offering, so
3
+ // we never upscale past what the source footage actually carries.
4
+
5
+ import type { EditorProject as Project } from '../schema'
6
+ import { trackItems } from './timeline/timeline-model'
7
+
8
+ /** Short-side "class" a tier is named after — 720p / 1080p / 1440p / 2160p. */
9
+ export const STANDARD_RESOLUTION_TIERS = [720, 1080, 1440, 2160] as const
10
+
11
+ export const STANDARD_FPS_TIERS = [24, 30, 60] as const
12
+
13
+ // ── Internal helpers ────────────────────────────────────────────────────────
14
+
15
+ /** Max, across video items with known source dims, of `min(sourceWidth, sourceHeight)`.
16
+ * Undefined when no video item carries usable dims. Goes through `trackItems()`
17
+ * (not `project.tracks` directly) so a project still on disk in the legacy
18
+ * `VisualItem[][]` shape — a bare array of item arrays, with no `.items` to
19
+ * read — is tolerated the same way every other reader tolerates it. */
20
+ function maxSourceShortSide(project: Project): number | undefined {
21
+ let max: number | undefined
22
+ for (const items of trackItems(project)) {
23
+ for (const item of items) {
24
+ if (item.type !== 'video') continue
25
+ const { sourceWidth: sw, sourceHeight: sh } = item
26
+ if (!sw || !sh || sw <= 0 || sh <= 0) continue
27
+ const short = Math.min(sw, sh)
28
+ if (max === undefined || short > max) max = short
29
+ }
30
+ }
31
+ return max
32
+ }
33
+
34
+ /** Converts a short-side tier to [w,h], preserving the project's aspect and
35
+ * orientation. Long side is rounded to the nearest EVEN integer (encoders
36
+ * demand even dims). */
37
+ function tierToResolution(tier: number, project: Project): [number, number] {
38
+ const [pw, ph] = project.settings.resolution
39
+ const shortP = Math.min(pw, ph)
40
+ const longP = Math.max(pw, ph)
41
+ if (shortP === 0) return [tier, tier]
42
+
43
+ const ratio = longP / shortP
44
+ const longSide = Math.round((tier * ratio) / 2) * 2
45
+ return pw <= ph ? [tier, longSide] : [longSide, tier]
46
+ }
47
+
48
+ // ── Public API ───────────────────────────────────────────────────────────────
49
+
50
+ /** Source-capped export resolution ceiling as [w,h]. Falls back to
51
+ * `project.settings.resolution` verbatim when no video clip carries source
52
+ * dims, or when the source is smaller than the smallest standard tier. */
53
+ export function maxExportResolution(project: Project): [number, number] {
54
+ const maxShort = maxSourceShortSide(project)
55
+ if (maxShort === undefined) return project.settings.resolution
56
+
57
+ const tier = [...STANDARD_RESOLUTION_TIERS].reverse().find(t => t <= maxShort)
58
+ if (tier === undefined) return project.settings.resolution
59
+
60
+ return tierToResolution(tier, project)
61
+ }
62
+
63
+ /** Source-capped export fps ceiling. No per-clip source-fps exists in the
64
+ * schema, so this is just the project's own setting (default 30). */
65
+ export function maxExportFps(project: Project): number {
66
+ return project.settings.fps ?? 30
67
+ }
68
+
69
+ /** Every resolution tier offerable in the export dialog, as [w,h] preserving
70
+ * project aspect, filtered to the source cap and ordered ascending. Always
71
+ * non-empty — falls back to the project's current resolution when the source
72
+ * is below every standard tier. */
73
+ export function availableResolutionTiers(project: Project): Array<[number, number]> {
74
+ const maxShort = maxSourceShortSide(project)
75
+ const capShort = maxShort ?? Math.min(...project.settings.resolution)
76
+
77
+ const tiers = STANDARD_RESOLUTION_TIERS.filter(t => t <= capShort).map(t => tierToResolution(t, project))
78
+ return tiers.length > 0 ? tiers : [project.settings.resolution]
79
+ }
80
+
81
+ /** Every fps tier offerable in the export dialog, filtered to the source cap
82
+ * and ordered ascending. Always non-empty. */
83
+ export function availableFpsTiers(project: Project): number[] {
84
+ const cap = maxExportFps(project)
85
+ const tiers = STANDARD_FPS_TIERS.filter(f => f <= cap)
86
+ return tiers.length > 0 ? tiers : [cap]
87
+ }
88
+
89
+ /** The tier from `availableResolutionTiers` matching the project's CURRENT
90
+ * `settings.resolution` (by short-side), or the nearest smaller one.
91
+ * Convenience for the export dialog's default selection. */
92
+ export function currentResolutionTier(project: Project): [number, number] | undefined {
93
+ const tiers = availableResolutionTiers(project)
94
+ if (tiers.length === 0) return undefined
95
+
96
+ const currentShort = Math.min(...project.settings.resolution)
97
+ const exact = tiers.find(([w, h]) => Math.min(w, h) === currentShort)
98
+ if (exact) return exact
99
+
100
+ const smaller = [...tiers].reverse().find(([w, h]) => Math.min(w, h) <= currentShort)
101
+ return smaller ?? tiers[0] // current is below every offered tier — nearest is the smallest one
102
+ }
@@ -0,0 +1,102 @@
1
+ import { useSyncExternalStore } from 'react'
2
+ import type { PlaybackClock } from './playback-clock'
3
+
4
+ /**
5
+ * The preview-axis override: "show the frame at THIS time instead of the
6
+ * playhead's", or null for "show the playhead's frame".
7
+ *
8
+ * Separate from the playback clock on purpose. The clock is the truth — it is
9
+ * where playback resumes, what the red playhead draws from, and what the
10
+ * scrubber and transport move. Hover-scrubbing must not touch any of that; it
11
+ * only changes which frame the preview paints, and only while the pointer is
12
+ * over the timeline. Writing hover into the clock instead would drag the red
13
+ * playhead and the scrubber handle around with the mouse, and Space would then
14
+ * start playing from wherever the pointer happened to rest.
15
+ *
16
+ * An external store rather than React state because it is written on every
17
+ * mousemove: only `PreviewPlayer` subscribes, so the editor shell, the timeline
18
+ * and the caption row don't re-render as the pointer travels.
19
+ */
20
+ export interface HoverScrub {
21
+ get(): number | null
22
+ set(t: number | null): void
23
+ subscribe(cb: () => void): () => void
24
+ }
25
+
26
+ export function createHoverScrub(): HoverScrub {
27
+ let time: number | null = null
28
+ const subs = new Set<() => void>()
29
+ return {
30
+ get: () => time,
31
+ set(t) {
32
+ // The no-op guard matters: `clock.subscribe` clears this store on every
33
+ // playback tick (see VideoEditor), which would otherwise notify ~60
34
+ // times a second forever.
35
+ if (t === time) return
36
+ time = t
37
+ subs.forEach((cb) => cb())
38
+ },
39
+ subscribe(cb) {
40
+ subs.add(cb)
41
+ return () => { subs.delete(cb) }
42
+ },
43
+ }
44
+ }
45
+
46
+ /** Stable snapshot for an absent store, so `useSyncExternalStore` isn't handed
47
+ * a fresh closure per render when a host omits hover-scrubbing entirely. */
48
+ const NO_SUBSCRIBE = () => () => {}
49
+ const NO_TIME = () => null
50
+
51
+ export function useHoverScrubTime(store?: HoverScrub): number | null {
52
+ return useSyncExternalStore(
53
+ store?.subscribe ?? NO_SUBSCRIBE,
54
+ store?.get ?? NO_TIME,
55
+ store?.get ?? NO_TIME,
56
+ )
57
+ }
58
+
59
+ /**
60
+ * The playback→editor half of the clock bridge, gated on hover-preview.
61
+ *
62
+ * Showing a hovered frame means SEEKING the source to it, and a seek runs a
63
+ * tick that reports the new position back (`useEnginePlayback`'s `emitTime`,
64
+ * the legacy hook's `onTimeUpdate`). Wired straight to `clock.set`, that makes
65
+ * the red playhead chase the yellow cursor across the timeline — the preview
66
+ * would be honest but the playhead would have silently moved, which is the one
67
+ * thing the preview axis promises it will not do.
68
+ *
69
+ * So while an override stands the picture is display-only and the clock is left
70
+ * alone. `handOverToHover` drops the override the moment playback starts, so
71
+ * playback's own emissions always get through.
72
+ */
73
+ export function gateTimeSink(clock: PlaybackClock, hover?: HoverScrub): (t: number) => void {
74
+ return (t: number) => {
75
+ if (hover?.get() != null) return
76
+ clock.set(t)
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Play from the yellow line. Called when playback starts: the source is already
82
+ * parked on the hovered frame (that is how it got on screen), so playback
83
+ * continues from there — this makes the RED playhead say so too, instead of
84
+ * leaving it stranded where it sat before the hover.
85
+ *
86
+ * Writing the clock is what makes the hand-over safe rather than cosmetic: with
87
+ * the override gone the preview falls back to the clock, and a clock still
88
+ * holding the pre-hover position reads as an external scrub and seeks the
89
+ * engine backwards mid-playback (`useEnginePlayback`'s scrub effect has no
90
+ * transport guard). Setting it to the hovered time keeps mirror and clock
91
+ * agreeing, so that seek never fires.
92
+ *
93
+ * Returns whether a hand-over happened — false when nothing was hovered, which
94
+ * is every ordinary press of play.
95
+ */
96
+ export function handOverToHover(clock: PlaybackClock, hover?: HoverScrub): boolean {
97
+ const t = hover?.get()
98
+ if (t == null) return false
99
+ hover!.set(null)
100
+ clock.set(t)
101
+ return true
102
+ }
@@ -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
+ }