@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,706 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { ChevronDown, ChevronRight, Crop } from 'lucide-react'
3
+ import type { AudioTrack, EditorProject, VisualItem } from '../../schema'
4
+ import { setClipSpeed } from '../cuts'
5
+ import SpeedControl from '../timeline/SpeedControl'
6
+ import VolumeControl from '../timeline/VolumeControl'
7
+ import { cn, inspectorInputClass, NumberField, stepValue, Switch } from '../../ui'
8
+ import { usePersistentState } from '../../ui/usePersistentState'
9
+ import TabNav, { type TabNavTab } from './TabNav'
10
+
11
+ /**
12
+ * `<ClipPropertiesPanel>` — the editor's contextual right-hand properties
13
+ * panel for a selected VIDEO CLIP or AUDIO TRACK. The sibling of
14
+ * `OverlayInspector` (the overlay Transform panel, which this panel now also
15
+ * hosts as its clip Transform tab): the two mount in the same column and
16
+ * share its visual language — `var(--editor-*)` custom properties,
17
+ * `inspectorInputClass` inputs, the same section chrome — so the column
18
+ * reads as one system no matter which one is showing.
19
+ *
20
+ * These fields used to live only in the host-side `ClipInspectModal`
21
+ * (`montaj_assets/ui`), opened by double-click. This panel reimplements the
22
+ * editor-managed subset of that modal's fields (NOT the AI-generation /
23
+ * regenerate surface, which stays host-owned and is injected via
24
+ * `generationSlot`; NOT delete, which stays modal-only — see the doc comment
25
+ * on `AudioSection` for why).
26
+ *
27
+ * Props-driven and host-agnostic, like the rest of this package: it never
28
+ * reaches into a store. All persistence flows back through the six
29
+ * preview/commit/change callbacks, which the host wires to its own sync core.
30
+ */
31
+ export type ClipSelection = { kind: 'clip'; item: VisualItem } | { kind: 'audio'; track: AudioTrack } | null
32
+
33
+ export interface ClipPropertiesPanelProps {
34
+ selection: ClipSelection
35
+ /** Live-preview a continuously-changing clip edit (slider drag): no undo
36
+ * entry, no save yet. Mirrors OverlayInspector's onPreview. */
37
+ onPreviewClip: (item: VisualItem) => void
38
+ /** Commit the last previewed clip edit as one undo step + queued save. */
39
+ onCommitClip: () => void
40
+ /** A discrete, already-final clip edit (a toggle, a preset chip). */
41
+ onChangeClip: (item: VisualItem) => void
42
+ /** Same three, for a selected audio track. */
43
+ onPreviewAudio: (track: AudioTrack) => void
44
+ onCommitAudio: () => void
45
+ onChangeAudio: (track: AudioTrack) => void
46
+ /** Transform tab body for a selected clip: the host's `OverlayInspector`
47
+ * instance, wired to the playback clock and the host's own sync/seek
48
+ * handlers — none of which this props-driven package has on hand, so the
49
+ * host builds it and hands it in rather than this panel importing and
50
+ * wiring `OverlayInspector` itself. Absent -> no Transform tab. */
51
+ transformSlot?: ReactNode
52
+ /** Present -> a Crop tab appears whose body is a single button that calls
53
+ * this. The caller decides WHETHER cropping applies to the current
54
+ * selection (e.g. only a tracks[0] video) — this panel just renders the
55
+ * tab it's offered and defers to a host-owned crop tool. Absent -> no
56
+ * Crop tab. */
57
+ onOpenCrop?: () => void
58
+ /** Generate tab body when a video clip is selected. Montaj puts its AI
59
+ * generation / regenerate surface here: that reads and writes
60
+ * `project.regenQueue`, a host-only field this package deliberately knows
61
+ * nothing about (see schema.ts's EditorProject index-signature comment).
62
+ * Absent -> no Generate tab. */
63
+ generationSlot?: ReactNode
64
+ /** Editor theme mode — light/dark. Only affects the Crop tab's icon badge
65
+ * hue (indigo-400 is sub-AA on a light `--editor-surface`). Absent ->
66
+ * dark, matching every existing caller. */
67
+ mode?: 'light' | 'dark'
68
+ }
69
+
70
+ const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
71
+ const ROW_LABEL_CLASS = 'w-16 shrink-0 text-[11px] text-[var(--editor-text)]/55'
72
+
73
+ function basename(path: string): string {
74
+ return path.split('/').pop() ?? path
75
+ }
76
+
77
+ /** One collapsible section frame, shared by the Audio track section and its
78
+ * Fades/Ducking sub-groups (via `nested`). Mirrors OverlayInspector's
79
+ * Transform section header exactly (chevron + uppercase label), scaled
80
+ * down for a nested sub-group. The Clip side used to share this too, before
81
+ * its fields became tabbed (a tab has nothing to fold). */
82
+ function CollapsibleSection({
83
+ label,
84
+ collapsed,
85
+ onToggle,
86
+ nested,
87
+ badge,
88
+ children,
89
+ }: {
90
+ label: string
91
+ collapsed: boolean
92
+ onToggle: () => void
93
+ nested?: boolean
94
+ badge?: ReactNode
95
+ children: ReactNode
96
+ }) {
97
+ return (
98
+ <div className={nested ? 'rounded-md border border-[var(--editor-border)] overflow-hidden' : SECTION_CLASS}>
99
+ <div
100
+ className={
101
+ nested
102
+ ? 'shrink-0 flex items-center gap-1 px-2 py-1.5'
103
+ : 'shrink-0 flex items-center gap-1 border-b border-[var(--editor-border)] px-2 py-1.5'
104
+ }
105
+ >
106
+ <button
107
+ type="button"
108
+ aria-expanded={!collapsed}
109
+ onClick={onToggle}
110
+ className="flex items-center gap-1 rounded px-1 py-0.5 text-[var(--editor-text)]/60 transition-colors hover:text-[var(--editor-text)]"
111
+ >
112
+ {collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
113
+ <span className={nested ? 'text-[11px] font-medium' : 'text-xs font-medium uppercase tracking-wide'}>{label}</span>
114
+ </button>
115
+ {badge && <div className="ml-auto">{badge}</div>}
116
+ </div>
117
+ {!collapsed && <div className={nested ? 'flex flex-col gap-2 border-t border-[var(--editor-border)] p-2' : 'flex flex-col gap-3 p-2'}>{children}</div>}
118
+ </div>
119
+ )
120
+ }
121
+
122
+ /** A label + control row, matching OverlayInspector's `ROW_LABEL_CLASS` rows. */
123
+ function Row({ label, children }: { label: string; children: ReactNode }) {
124
+ return (
125
+ <div className="flex items-center gap-2">
126
+ <span className={ROW_LABEL_CLASS}>{label}</span>
127
+ {children}
128
+ </div>
129
+ )
130
+ }
131
+
132
+ interface DraftFieldProps {
133
+ ariaLabel: string
134
+ value: string
135
+ /** Raw text as the operator types it. Validate/parse here, not in this
136
+ * component — different fields want different rules (e.g. an empty
137
+ * string or a lone "-" mid-number is not yet a value to preview). */
138
+ onInput: (raw: string) => void
139
+ /** Fired on blur, or on Enter (via the same blur path). Carries the exact
140
+ * string on screen at that moment, so a caller that needs to resolve a
141
+ * final value (e.g. an empty label falling back to the source basename)
142
+ * can do so without keeping its own shadow copy of what was typed. */
143
+ onCommit: (raw: string) => void
144
+ className?: string
145
+ }
146
+
147
+ /**
148
+ * One TEXT box, split out so it can hold its OWN `useState` — every field in
149
+ * this panel needs independent "am I mid-edit" state, exactly like
150
+ * `OverlayInspectorField` in the sibling panel. Every NUMBER field in this
151
+ * panel is a shared `NumberField` (ui/NumberField.tsx) instead — this
152
+ * component used to serve both, but the number branch is retired now that
153
+ * NumberField owns the same draft/preview/commit contract plus steppers; the
154
+ * only field left that needs it is the audio track Label, which is text.
155
+ *
156
+ * `draft` is the fix for a real, previously-shipped bug: this whole panel can
157
+ * re-render from outside (a playback tick, an unrelated field's edit) while
158
+ * the operator is mid-keystroke. A controlled input bound straight to the
159
+ * incoming prop gets its keystrokes clobbered by that re-render. `draft` goes
160
+ * non-null the moment the operator types and stays the single source of truth
161
+ * for what's ON SCREEN until blur/Enter commits it, so an outside re-render
162
+ * arriving mid-edit can't overwrite what's displayed. While `draft` is null
163
+ * (not currently being typed into) the field tracks `value` live, exactly as
164
+ * it always did.
165
+ */
166
+ function DraftField({ ariaLabel, value, onInput, onCommit, className }: DraftFieldProps) {
167
+ const [draft, setDraft] = useState<string | null>(null)
168
+ const shown = draft ?? value
169
+
170
+ function commit() {
171
+ const raw = shown
172
+ setDraft(null)
173
+ onCommit(raw)
174
+ }
175
+
176
+ return (
177
+ <input
178
+ type="text"
179
+ aria-label={ariaLabel}
180
+ value={shown}
181
+ onChange={e => { setDraft(e.target.value); onInput(e.target.value) }}
182
+ onBlur={commit}
183
+ // Enter closes the typing gesture the same way blur does — it does not
184
+ // duplicate the commit logic, just triggers the same onBlur path.
185
+ onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
186
+ className={cn(inspectorInputClass, className)}
187
+ />
188
+ )
189
+ }
190
+
191
+ /**
192
+ * `setClipSpeed` (cuts.ts) operates on a whole Project — it looks the clip up
193
+ * by id and rewrites its track — because a speed change also re-fits the
194
+ * clip's timeline `end` to keep the same source window at the new rate. This
195
+ * panel only ever sees the single `VisualItem` it's editing, with no project
196
+ * in scope. Wrapping the item in a throwaway single-item, single-track
197
+ * project reuses `setClipSpeed`'s exact duration math with no project on
198
+ * hand, rather than re-deriving it here — the same "minimal project, only
199
+ * the fields the function touches" shape `cuts.test.ts`'s own `makeProject`
200
+ * fixture uses for the same function.
201
+ */
202
+ function withResolvedSpeed(item: VisualItem, speed: number): VisualItem {
203
+ const scratch: EditorProject = {
204
+ id: 'clip-properties-panel-scratch',
205
+ status: 'draft',
206
+ settings: { resolution: [0, 0] },
207
+ tracks: [{ id: 'scratch', items: [item] }],
208
+ }
209
+ const next = setClipSpeed(scratch, item.id, speed)
210
+ return next.tracks?.[0]?.items[0] ?? item
211
+ }
212
+
213
+ // ── Clip properties tabs ────────────────────────────────────────────────
214
+ // Which of the selected clip's tabs is showing. Persisted per browser like
215
+ // every other panel preference, so the operator's habit survives a reload.
216
+ // 'transform' is the default — geometry is what an operator reaches for
217
+ // first when a clip is selected, matching OverlayInspector's own Transform
218
+ // tab for overlays.
219
+ const CLIP_PANEL_TAB_STORAGE_KEY = 'montaj.editor.clipPanelTab'
220
+ type ClipPanelTab = 'transform' | 'speed' | 'volume' | 'crop' | 'generate'
221
+ const CLIP_PANEL_TAB_IDS: readonly ClipPanelTab[] = ['transform', 'speed', 'volume', 'crop', 'generate']
222
+ const reviveClipPanelTab = (raw: unknown): ClipPanelTab | null =>
223
+ typeof raw === 'string' && (CLIP_PANEL_TAB_IDS as readonly string[]).includes(raw) ? (raw as ClipPanelTab) : null
224
+
225
+ interface ClipTabsProps {
226
+ item: VisualItem
227
+ onPreviewClip: (item: VisualItem) => void
228
+ onCommitClip: () => void
229
+ onChangeClip: (item: VisualItem) => void
230
+ transformSlot?: ReactNode
231
+ onOpenCrop?: () => void
232
+ generationSlot?: ReactNode
233
+ mode?: 'light' | 'dark'
234
+ }
235
+
236
+ /**
237
+ * A selected clip's properties, tabbed: **Transform · Speed · Volume · Crop
238
+ * · Generate**, in that fixed order. A tab appears only when it has
239
+ * something to show for THIS selection — an image clip with no generation
240
+ * slot offers only Transform and Volume; a main-track video with both slots
241
+ * offers all five. Replaces the old flat `ClipSection` (Volume + Mute +
242
+ * video-only Speed stacked above the host's `generationSlot`).
243
+ */
244
+ function ClipTabs({ item, onPreviewClip, onCommitClip, onChangeClip, transformSlot, onOpenCrop, generationSlot, mode = 'dark' }: ClipTabsProps) {
245
+ const tabs: TabNavTab<ClipPanelTab>[] = []
246
+ if (transformSlot !== undefined) tabs.push({ value: 'transform', label: 'Transform' })
247
+ // Speed is video-only, matching the modal this replaces. `setClipSpeed`
248
+ // returns the project UNCHANGED for a non-video item (cuts.ts's
249
+ // `item.type !== 'video'` early return), so offering the tab for an image
250
+ // would give the operator a slider that silently does nothing.
251
+ if (item.type === 'video') tabs.push({ value: 'speed', label: 'Speed' })
252
+ tabs.push({ value: 'volume', label: 'Volume' })
253
+ if (onOpenCrop) tabs.push({ value: 'crop', label: 'Crop' })
254
+ if (generationSlot !== undefined) tabs.push({ value: 'generate', label: 'Generate' })
255
+
256
+ const [persistedTab, setPersistedTab] = usePersistentState<ClipPanelTab>(
257
+ CLIP_PANEL_TAB_STORAGE_KEY,
258
+ 'transform',
259
+ reviveClipPanelTab,
260
+ )
261
+
262
+ // Stale-tab safety AT RENDER TIME, not just on load: unlike LeftPanelTabs'
263
+ // static rail, this tab set is DERIVED FROM THE SELECTION — an image has
264
+ // no Speed tab, a clip with no `generationSlot` has no Generate tab — so it
265
+ // changes as the operator selects different clips. A persisted/active tab
266
+ // that isn't in the CURRENT set (persisted 'speed', then the operator
267
+ // selects an image) falls back to 'transform', or to the first tab this
268
+ // selection actually offers if even Transform is missing, rather than
269
+ // rendering a blank pane.
270
+ const currentTab: ClipPanelTab = tabs.some(t => t.value === persistedTab)
271
+ ? persistedTab
272
+ : tabs.some(t => t.value === 'transform')
273
+ ? 'transform'
274
+ : (tabs[0]?.value ?? 'volume')
275
+
276
+ // Lazy mount, then KEEP MOUNTED: a tab body isn't rendered until first
277
+ // activated, but once mounted it stays mounted and is only hidden on
278
+ // switch-away — exactly `LeftPanelTabs`' policy, and load-bearing here for
279
+ // the same reason it's load-bearing there. The Generate tab's slot seeds
280
+ // its regen form (prompt, model, duration, reference images) from
281
+ // `useState` initializers that run ONCE per mount; unmounting it on
282
+ // Generate -> Speed -> Generate would silently lose everything the
283
+ // operator had typed. Do not "optimize" this into a plain conditional
284
+ // render.
285
+ const [mountedTabs, setMountedTabs] = useState<Set<ClipPanelTab>>(() => new Set([currentTab]))
286
+ if (!mountedTabs.has(currentTab)) {
287
+ setMountedTabs(prev => {
288
+ const next = new Set(prev)
289
+ next.add(currentTab)
290
+ return next
291
+ })
292
+ }
293
+
294
+ const volume = item.volume ?? 1
295
+ const muted = item.muted ?? false
296
+ const speed = item.speed ?? 1
297
+
298
+ return (
299
+ <>
300
+ <TabNav
301
+ tabs={tabs}
302
+ value={currentTab}
303
+ onChange={setPersistedTab}
304
+ ariaLabel="Clip panel view"
305
+ className="shrink-0 border-b border-[var(--editor-border)] px-1"
306
+ />
307
+ {tabs
308
+ .filter(tab => mountedTabs.has(tab.value))
309
+ .map(tab => {
310
+ const active = tab.value === currentTab
311
+ return (
312
+ <div
313
+ key={tab.value}
314
+ hidden={!active}
315
+ style={active ? undefined : { display: 'none' }}
316
+ className="flex flex-col overflow-hidden"
317
+ >
318
+ {/* Transform/Generate render the host slot DIRECTLY — each
319
+ already brings its own section chrome (OverlayInspector
320
+ wraps itself in the same SECTION_CLASS this file uses).
321
+ Wrapping them again here would double up borders/padding. */}
322
+ {tab.value === 'transform' && transformSlot}
323
+ {tab.value === 'speed' && (
324
+ <div className="flex flex-col gap-2 p-2">
325
+ <SpeedControl
326
+ value={speed}
327
+ // The drag/type gesture only ever shows the SPEED number
328
+ // moving — mirroring ClipInspectModal's slider, which
329
+ // doesn't resize the clip until its Save button is
330
+ // pressed. Duration is re-fit exactly once, in onCommit
331
+ // below, via the real function.
332
+ onChange={v => onPreviewClip({ ...item, speed: v })}
333
+ onCommit={v => {
334
+ onPreviewClip(withResolvedSpeed(item, v))
335
+ onCommitClip()
336
+ }}
337
+ label="Speed"
338
+ idBase="clip-properties-speed"
339
+ />
340
+ </div>
341
+ )}
342
+ {tab.value === 'volume' && (
343
+ <div className="flex flex-col gap-2 p-2">
344
+ <VolumeControl
345
+ value={volume}
346
+ onChange={v => onPreviewClip({ ...item, volume: v })}
347
+ onCommit={() => onCommitClip()}
348
+ label="Volume"
349
+ idBase="clip-properties-volume"
350
+ />
351
+ <Row label="Mute">
352
+ <Switch checked={muted} onCheckedChange={next => onChangeClip({ ...item, muted: next })} aria-label="Mute clip" mode={mode} />
353
+ </Row>
354
+ </div>
355
+ )}
356
+ {tab.value === 'crop' && onOpenCrop && (
357
+ <div className="flex flex-col items-center gap-3 px-5 pt-8 pb-6 text-center">
358
+ <div className={`flex h-11 w-11 items-center justify-center rounded-full bg-indigo-500/10 ${mode === 'light' ? 'text-indigo-600' : 'text-indigo-400'}`}>
359
+ <Crop size={20} />
360
+ </div>
361
+ <p className="max-w-[220px] text-xs leading-relaxed text-[var(--editor-text)]/60">
362
+ Reframe this clip by cropping its source video. Pick the part of the frame to keep.
363
+ </p>
364
+ <button
365
+ type="button"
366
+ onClick={onOpenCrop}
367
+ className="inline-flex items-center gap-1.5 rounded-md bg-[var(--editor-accent)] px-3 py-1.5 text-xs font-medium text-[var(--editor-accent-foreground)] transition-opacity hover:opacity-90"
368
+ >
369
+ <Crop size={13} />
370
+ Open crop tool
371
+ </button>
372
+ </div>
373
+ )}
374
+ {tab.value === 'generate' && generationSlot}
375
+ </div>
376
+ )
377
+ })}
378
+ </>
379
+ )
380
+ }
381
+
382
+ interface AudioSectionProps {
383
+ track: AudioTrack
384
+ onPreviewAudio: (track: AudioTrack) => void
385
+ onCommitAudio: () => void
386
+ onChangeAudio: (track: AudioTrack) => void
387
+ /** Editor theme mode — light/dark. Only affects the Mute/Ducking switches'
388
+ * unchecked track colour. Absent -> dark, matching every existing caller. */
389
+ mode?: 'light' | 'dark'
390
+ }
391
+
392
+ /**
393
+ * Every editor-managed audio-track field EXCEPT delete. Deliberate: this
394
+ * panel appears the moment a track is plain-selected, and a destructive
395
+ * action one click away from selecting is a foot-gun. `ClipInspectModal`
396
+ * (host-side, opened by double-click — a more deliberate action) keeps the
397
+ * delete control.
398
+ */
399
+ function AudioSection({ track, onPreviewAudio, onCommitAudio, onChangeAudio, mode = 'dark' }: AudioSectionProps) {
400
+ const [collapsed, setCollapsed] = useState(false)
401
+ const [fadesOpen, setFadesOpen] = useState(false)
402
+ const [duckingOpen, setDuckingOpen] = useState(false)
403
+
404
+ const volume = track.volume ?? 1
405
+ const muted = track.muted ?? false
406
+ const fadeIn = track.fadeIn ?? 0
407
+ const fadeOut = track.fadeOut ?? 0
408
+ const duckingEnabled = track.ducking?.enabled ?? false
409
+ const duckingDepth = track.ducking?.depth ?? -12
410
+ const duckingAttack = track.ducking?.attack ?? 0.3
411
+ const duckingRelease = track.ducking?.release ?? 0.5
412
+ const inPoint = track.inPoint ?? 0
413
+ const outPoint = track.outPoint ?? (track.sourceDuration ?? (track.end - track.start))
414
+ const start = track.start
415
+ const end = track.end
416
+
417
+ /** Constraints the retired ClipInspectModal enforced on these same fields.
418
+ * They are re-applied HERE, on the way out, rather than left to the number
419
+ * inputs' `min`/`max` attributes: a typed value bypasses those entirely, so
420
+ * without this an operator can commit `end <= start` (a negative-duration
421
+ * track), trim points outside the source, or a fade longer than the clip.
422
+ * Each clamp is applied only when its own field is the one being edited, so
423
+ * editing one field never silently rewrites another. */
424
+ function clampAudioPatch(patch: Partial<AudioTrack>): Partial<AudioTrack> {
425
+ const next = { ...patch }
426
+ const srcDur = track.sourceDuration ?? Infinity
427
+
428
+ // `end` must stay strictly after `start` (and vice versa). EPS rather than
429
+ // equality: a zero-length track is as broken as a negative one.
430
+ const EPS = 0.001
431
+ if (next.end !== undefined) next.end = Math.max(start + EPS, next.end)
432
+ if (next.start !== undefined) next.start = Math.min(end - EPS, Math.max(0, next.start))
433
+
434
+ // Trim points stay inside the source and never cross each other.
435
+ if (next.inPoint !== undefined) next.inPoint = Math.max(0, Math.min(next.inPoint, outPoint))
436
+ if (next.outPoint !== undefined) {
437
+ next.outPoint = Math.max(inPoint, srcDur === Infinity ? next.outPoint : Math.min(next.outPoint, srcDur))
438
+ }
439
+
440
+ // A fade can't outrun the clip it's fading, and the modal capped it at 5s.
441
+ const maxFade = Math.max(0, Math.min(5, (next.end ?? end) - (next.start ?? start)))
442
+ if (next.fadeIn !== undefined) next.fadeIn = Math.max(0, Math.min(next.fadeIn, maxFade))
443
+ if (next.fadeOut !== undefined) next.fadeOut = Math.max(0, Math.min(next.fadeOut, maxFade))
444
+
445
+ return next
446
+ }
447
+
448
+ function previewAudio(patch: Partial<AudioTrack>) {
449
+ onPreviewAudio({ ...track, ...clampAudioPatch(patch) })
450
+ }
451
+
452
+ function previewDucking(patch: Partial<NonNullable<AudioTrack['ducking']>>) {
453
+ onPreviewAudio({
454
+ ...track,
455
+ ducking: { enabled: duckingEnabled, depth: duckingDepth, attack: duckingAttack, release: duckingRelease, ...patch },
456
+ })
457
+ }
458
+
459
+ function toggleDucking(next: boolean) {
460
+ // Enabling seeds every field with its current-or-default value (never a
461
+ // half-empty object); disabling only flips the flag, so re-enabling
462
+ // brings back whatever the operator had tuned.
463
+ onChangeAudio({
464
+ ...track,
465
+ ducking: next
466
+ ? { enabled: true, depth: duckingDepth, attack: duckingAttack, release: duckingRelease }
467
+ : { ...track.ducking, enabled: false },
468
+ })
469
+ }
470
+
471
+ return (
472
+ <CollapsibleSection label="Audio track" collapsed={collapsed} onToggle={() => setCollapsed(c => !c)}>
473
+ <Row label="Label">
474
+ <DraftField
475
+ ariaLabel="Track label"
476
+ value={track.label ?? basename(track.src)}
477
+ onInput={raw => previewAudio({ label: raw })}
478
+ onCommit={raw => {
479
+ // Falls back to the source basename on save, exactly like
480
+ // ClipInspectModal's AudioInspect — an emptied label never
481
+ // persists as an unlabeled track.
482
+ const finalLabel = raw.trim() || basename(track.src)
483
+ onPreviewAudio({ ...track, label: finalLabel })
484
+ onCommitAudio()
485
+ }}
486
+ />
487
+ </Row>
488
+
489
+ <VolumeControl
490
+ value={volume}
491
+ onChange={v => previewAudio({ volume: v })}
492
+ onCommit={() => onCommitAudio()}
493
+ label="Volume"
494
+ idBase="clip-properties-audio-volume"
495
+ />
496
+ <Row label="Mute">
497
+ <Switch checked={muted} onCheckedChange={next => onChangeAudio({ ...track, muted: next })} aria-label="Mute track" mode={mode} />
498
+ </Row>
499
+
500
+ <CollapsibleSection label="Fades" collapsed={!fadesOpen} onToggle={() => setFadesOpen(o => !o)} nested>
501
+ <Row label="Fade in">
502
+ <NumberField
503
+ name="Fade in"
504
+ className="w-20"
505
+ value={fadeIn}
506
+ step={0.1}
507
+ min={0}
508
+ max={5}
509
+ // `min`/`max` on the box are a native spinner hint only (see
510
+ // NumberField's own doc comment). The REAL floor for this row —
511
+ // and for Fade out, Start, In point and Out point below — is
512
+ // `clampAudioPatch`, which `previewAudio` and every `onStep`
513
+ // handler on those fields route their patch through; it floors
514
+ // fadeIn/fadeOut/start/inPoint at 0 and derives floors for
515
+ // end/outPoint. The inline `Math.max(0, v)` calls on THIS group
516
+ // of fields are therefore redundant (harmless — matches the
517
+ // pre-refactor behaviour) rather than load-bearing.
518
+ //
519
+ // That is NOT true of the two DUCKING rows below (Attack,
520
+ // Release): `previewDucking` does no clamping of its own at all,
521
+ // so their `Math.max(0, v)` calls are the only floor those two
522
+ // fields have. Do not remove those thinking this comment covers
523
+ // them — they are the deliberate exception.
524
+ onPreview={v => previewAudio({ fadeIn: Math.max(0, v) })}
525
+ onCommit={() => onCommitAudio()}
526
+ onStep={d => onChangeAudio({ ...track, ...clampAudioPatch({ fadeIn: stepValue(fadeIn, d, { step: 0.1, min: 0, max: 5 }) }) })}
527
+ />
528
+ </Row>
529
+ <Row label="Fade out">
530
+ <NumberField
531
+ name="Fade out"
532
+ className="w-20"
533
+ value={fadeOut}
534
+ step={0.1}
535
+ min={0}
536
+ max={5}
537
+ onPreview={v => previewAudio({ fadeOut: Math.max(0, v) })}
538
+ onCommit={() => onCommitAudio()}
539
+ onStep={d => onChangeAudio({ ...track, ...clampAudioPatch({ fadeOut: stepValue(fadeOut, d, { step: 0.1, min: 0, max: 5 }) }) })}
540
+ />
541
+ </Row>
542
+ </CollapsibleSection>
543
+
544
+ <CollapsibleSection
545
+ label="Ducking"
546
+ collapsed={!duckingOpen}
547
+ onToggle={() => setDuckingOpen(o => !o)}
548
+ nested
549
+ badge={duckingEnabled
550
+ // ~10px accent text → the AA-safe accent-text token (indigo-600 in
551
+ // light, where the plain indigo-500 accent is ~4.06:1, under 4.5:1).
552
+ ? <span className="text-[10px] text-[var(--editor-accent-text)]">On</span>
553
+ : undefined}
554
+ >
555
+ <Row label="Enabled">
556
+ <Switch checked={duckingEnabled} onCheckedChange={toggleDucking} aria-label="Enable ducking" mode={mode} />
557
+ </Row>
558
+ <Row label="Depth">
559
+ <NumberField
560
+ name="Depth"
561
+ className="w-20"
562
+ step={1}
563
+ disabled={!duckingEnabled}
564
+ value={duckingDepth}
565
+ // No floor: ducking depth is a dB attenuation and the modal this
566
+ // panel replaces never bounded it either.
567
+ onPreview={v => previewDucking({ depth: v })}
568
+ onCommit={() => onCommitAudio()}
569
+ onStep={d => onChangeAudio({
570
+ ...track,
571
+ ducking: { enabled: duckingEnabled, depth: stepValue(duckingDepth, d, { step: 1 }), attack: duckingAttack, release: duckingRelease },
572
+ })}
573
+ />
574
+ </Row>
575
+ <Row label="Attack">
576
+ <NumberField
577
+ name="Attack"
578
+ className="w-20"
579
+ min={0}
580
+ step={0.05}
581
+ disabled={!duckingEnabled}
582
+ value={duckingAttack}
583
+ onPreview={v => previewDucking({ attack: Math.max(0, v) })}
584
+ onCommit={() => onCommitAudio()}
585
+ onStep={d => onChangeAudio({
586
+ ...track,
587
+ ducking: { enabled: duckingEnabled, depth: duckingDepth, attack: stepValue(duckingAttack, d, { step: 0.05, min: 0 }), release: duckingRelease },
588
+ })}
589
+ />
590
+ </Row>
591
+ <Row label="Release">
592
+ <NumberField
593
+ name="Release"
594
+ className="w-20"
595
+ min={0}
596
+ step={0.05}
597
+ disabled={!duckingEnabled}
598
+ value={duckingRelease}
599
+ onPreview={v => previewDucking({ release: Math.max(0, v) })}
600
+ onCommit={() => onCommitAudio()}
601
+ onStep={d => onChangeAudio({
602
+ ...track,
603
+ ducking: { enabled: duckingEnabled, depth: duckingDepth, attack: duckingAttack, release: stepValue(duckingRelease, d, { step: 0.05, min: 0 }) },
604
+ })}
605
+ />
606
+ </Row>
607
+ </CollapsibleSection>
608
+
609
+ {/* Timeline position. Plain writes, exactly as the modal saved them (no
610
+ derived math, unlike a clip speed change): these are the same values a
611
+ drag on the timeline sets, offered numerically so the panel is full
612
+ parity with the modal it replaces and the modal can retire outright. */}
613
+ <Row label="Start">
614
+ <NumberField
615
+ name="Start"
616
+ className="w-20"
617
+ min={0}
618
+ step={0.1}
619
+ value={start}
620
+ onPreview={v => previewAudio({ start: Math.max(0, v) })}
621
+ onCommit={() => onCommitAudio()}
622
+ onStep={d => onChangeAudio({ ...track, ...clampAudioPatch({ start: stepValue(start, d, { step: 0.1, min: 0 }) }) })}
623
+ />
624
+ </Row>
625
+ <Row label="End">
626
+ <NumberField
627
+ name="End"
628
+ className="w-20"
629
+ min={0}
630
+ step={0.1}
631
+ value={end}
632
+ onPreview={v => previewAudio({ end: Math.max(0, v) })}
633
+ onCommit={() => onCommitAudio()}
634
+ onStep={d => onChangeAudio({ ...track, ...clampAudioPatch({ end: stepValue(end, d, { step: 0.1, min: 0 }) }) })}
635
+ />
636
+ </Row>
637
+ <Row label="In point">
638
+ <NumberField
639
+ name="In point"
640
+ className="w-20"
641
+ min={0}
642
+ step={0.1}
643
+ value={inPoint}
644
+ onPreview={v => previewAudio({ inPoint: Math.max(0, v) })}
645
+ onCommit={() => onCommitAudio()}
646
+ onStep={d => onChangeAudio({ ...track, ...clampAudioPatch({ inPoint: stepValue(inPoint, d, { step: 0.1, min: 0 }) }) })}
647
+ />
648
+ </Row>
649
+ <Row label="Out point">
650
+ <NumberField
651
+ name="Out point"
652
+ className="w-20"
653
+ min={0}
654
+ step={0.1}
655
+ value={outPoint}
656
+ onPreview={v => previewAudio({ outPoint: Math.max(0, v) })}
657
+ onCommit={() => onCommitAudio()}
658
+ onStep={d => onChangeAudio({ ...track, ...clampAudioPatch({ outPoint: stepValue(outPoint, d, { step: 0.1, min: 0 }) }) })}
659
+ />
660
+ </Row>
661
+ </CollapsibleSection>
662
+ )
663
+ }
664
+
665
+ export default function ClipPropertiesPanel({
666
+ selection,
667
+ onPreviewClip,
668
+ onCommitClip,
669
+ onChangeClip,
670
+ onPreviewAudio,
671
+ onCommitAudio,
672
+ onChangeAudio,
673
+ transformSlot,
674
+ onOpenCrop,
675
+ generationSlot,
676
+ mode = 'dark',
677
+ }: ClipPropertiesPanelProps) {
678
+ if (!selection) {
679
+ return (
680
+ <div className={SECTION_CLASS}>
681
+ <div className="px-3 py-6 text-center text-[11px] text-[var(--editor-text)]/45">
682
+ Select a clip to edit its properties.
683
+ </div>
684
+ </div>
685
+ )
686
+ }
687
+
688
+ if (selection.kind === 'clip') {
689
+ return (
690
+ <ClipTabs
691
+ item={selection.item}
692
+ onPreviewClip={onPreviewClip}
693
+ onCommitClip={onCommitClip}
694
+ onChangeClip={onChangeClip}
695
+ transformSlot={transformSlot}
696
+ onOpenCrop={onOpenCrop}
697
+ generationSlot={generationSlot}
698
+ mode={mode}
699
+ />
700
+ )
701
+ }
702
+
703
+ return (
704
+ <AudioSection track={selection.track} onPreviewAudio={onPreviewAudio} onCommitAudio={onCommitAudio} onChangeAudio={onChangeAudio} mode={mode} />
705
+ )
706
+ }