@bycrux/editor 0.11.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -1,404 +0,0 @@
1
- // AudioTrackRow — renders one audio lane on the timeline.
2
- //
3
- // A lane can contain one or more AudioTrack items (e.g. two voiceover segments
4
- // at different times sharing the same row). Supports: drag-to-reposition,
5
- // cross-lane drag, edge trim, mute toggle, inline volume, delete,
6
- // click-to-select, and inspect button.
7
-
8
- import { memo, useEffect, useRef, useState } from 'react'
9
- import { Volume2, VolumeX, Trash2, Info } from 'lucide-react'
10
- import type { AudioTrack } from '../../schema'
11
- import type { Project } from '../../types'
12
- import { pct } from './utils'
13
- import { useTimelineContext } from './TimelineContext'
14
- import PlayheadLine from './PlayheadLine'
15
- import { useItemDragDrop } from './useItemDragDrop'
16
- import type { Draggable, DragEventContext } from './useItemDragDrop'
17
- import AudioWaveformLayer from './AudioWaveformLayer'
18
- import type { GetWaveformChunks, ResolveFilePath } from './AudioWaveformLayer'
19
- import { applyMuteToSelection, applyResizeDeltaToSelection } from './multiSelectOps'
20
-
21
- interface AudioTrackRowProps {
22
- tracks: AudioTrack[]
23
- laneIndex: number
24
- laneCount: number
25
- project: Project
26
- onProjectChange?: (p: Project) => void
27
- onOverlayEdit?: (p: Project) => void
28
- /** Unified multi-selection — same array shared across visual + audio rows. */
29
- selectedIds: string[]
30
- onSelectItem: (id: string | null, additive: boolean) => void
31
- onInspect?: (id: string) => void
32
- getWaveformChunks?: GetWaveformChunks
33
- resolveFilePath?: ResolveFilePath
34
- }
35
-
36
- const LANE_HEIGHT_PX = 40 // must match the h-10 (2.5rem = 40px) on the row container
37
-
38
- function updateAudioTrack(project: Project, trackId: string, changes: Partial<AudioTrack>): Project {
39
- return {
40
- ...project,
41
- audio: {
42
- ...project.audio,
43
- tracks: (project.audio?.tracks ?? []).map(t =>
44
- t.id === trackId ? { ...t, ...changes } : t,
45
- ),
46
- },
47
- }
48
- }
49
-
50
- function AudioTrackRow({
51
- tracks,
52
- laneIndex,
53
- laneCount,
54
- project,
55
- onProjectChange,
56
- onOverlayEdit,
57
- selectedIds,
58
- onSelectItem,
59
- onInspect,
60
- getWaveformChunks,
61
- resolveFilePath,
62
- }: AudioTrackRowProps) {
63
- const {
64
- totalDuration,
65
- snapBoundaries,
66
- scrollRef,
67
- overlayDraggedRef,
68
- zoomRef,
69
- } = useTimelineContext()
70
-
71
- const { beginDrag, beginResize } = useItemDragDrop({
72
- totalDuration,
73
- snapBoundaries,
74
- scrollRef,
75
- zoomRef,
76
- draggedFlagRef: overlayDraggedRef,
77
- })
78
-
79
- return (
80
- <div className="relative h-10 bg-gray-100 dark:bg-gray-900 rounded overflow-hidden cursor-pointer">
81
- <PlayheadLine />
82
- {tracks.map(track => (
83
- <AudioTrackItem
84
- key={track.id}
85
- track={track}
86
- laneIndex={laneIndex}
87
- laneCount={laneCount}
88
- project={project}
89
- totalDuration={totalDuration}
90
- selected={selectedIds.includes(track.id)}
91
- selectedIds={selectedIds}
92
- onSelectItem={onSelectItem}
93
- onInspect={onInspect}
94
- onProjectChange={onProjectChange}
95
- onOverlayEdit={onOverlayEdit}
96
- beginDrag={beginDrag}
97
- beginResize={beginResize}
98
- overlayDraggedRef={overlayDraggedRef}
99
- getWaveformChunks={getWaveformChunks}
100
- resolveFilePath={resolveFilePath}
101
- />
102
- ))}
103
- {/* Crossfade indicators — shown in the overlap zone between two tracks */}
104
- {(() => {
105
- const sorted = [...tracks].filter(t => !t.muted).sort((a, b) => a.start - b.start)
106
- const indicators = []
107
- for (let i = 0; i < sorted.length - 1; i++) {
108
- const a = sorted[i], b = sorted[i + 1]
109
- if (a.end > b.start) {
110
- const overlapStart = b.start
111
- const overlapEnd = Math.min(a.end, b.end)
112
- indicators.push(
113
- <div
114
- key={`xfade-${a.id}-${b.id}`}
115
- className="absolute top-0 bottom-0 pointer-events-none z-[5] flex items-center justify-center"
116
- style={{
117
- left: `${pct(overlapStart, totalDuration)}%`,
118
- width: `${pct(overlapEnd - overlapStart, totalDuration)}%`,
119
- }}
120
- >
121
- {/* Crossfade background highlight */}
122
- <div className="absolute inset-0 bg-amber-400/15 border-x border-amber-400/30" />
123
- {/* Crossfade icon — two crossing lines */}
124
- <svg width="16" height="16" viewBox="0 0 16 16" className="relative text-amber-300/70 drop-shadow-sm">
125
- <line x1="2" y1="12" x2="14" y2="4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
126
- <line x1="2" y1="4" x2="14" y2="12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
127
- </svg>
128
- </div>
129
- )
130
- }
131
- }
132
- return indicators
133
- })()}
134
- </div>
135
- )
136
- }
137
-
138
- export default memo(AudioTrackRow)
139
-
140
- // ── Single audio item within a lane ──────────────────────────────────────────
141
-
142
- interface AudioTrackItemProps {
143
- track: AudioTrack
144
- laneIndex: number
145
- laneCount: number
146
- project: Project
147
- totalDuration: number
148
- selected: boolean
149
- selectedIds: string[]
150
- onSelectItem: (id: string | null, additive: boolean) => void
151
- onInspect?: (id: string) => void
152
- onProjectChange?: (p: Project) => void
153
- onOverlayEdit?: (p: Project) => void
154
- beginDrag: ReturnType<typeof useItemDragDrop>['beginDrag']
155
- beginResize: ReturnType<typeof useItemDragDrop>['beginResize']
156
- overlayDraggedRef: React.RefObject<boolean>
157
- getWaveformChunks?: GetWaveformChunks
158
- resolveFilePath?: ResolveFilePath
159
- }
160
-
161
- function AudioTrackItem({
162
- track,
163
- laneIndex,
164
- laneCount: _laneCount,
165
- project,
166
- totalDuration,
167
- selected,
168
- selectedIds,
169
- onSelectItem,
170
- onInspect,
171
- onProjectChange,
172
- onOverlayEdit,
173
- beginDrag,
174
- beginResize,
175
- overlayDraggedRef,
176
- getWaveformChunks,
177
- resolveFilePath,
178
- }: AudioTrackItemProps) {
179
- const [confirmingDelete, setConfirmingDelete] = useState(false)
180
- const deleteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
181
- useEffect(() => () => { if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current) }, [])
182
-
183
- const left = pct(track.start, totalDuration)
184
- const width = pct(track.end - track.start, totalDuration)
185
- const label = track.label ?? track.src.split('/').pop() ?? 'audio'
186
-
187
- // ── Drag to reposition (horizontal + cross-lane) ──
188
- function handleDragStart(e: React.MouseEvent) {
189
- if ((e.target as HTMLElement).classList.contains('cursor-ew-resize')) return
190
- if (!onProjectChange) return
191
- let lastUpdated = project
192
-
193
- beginDrag(e, track as Draggable, {
194
- onLivePreview: ({ item: moved, dy }: DragEventContext) => {
195
- // Cross-lane: compute target lane from vertical drag.
196
- // Positive dy = dragged down = higher lane index.
197
- const laneDelta = Math.round(dy / LANE_HEIGHT_PX)
198
- const targetLane = Math.max(0, laneIndex + laneDelta)
199
-
200
- const next = updateAudioTrack(project, track.id, {
201
- start: moved.start,
202
- end: moved.end,
203
- lane: targetLane,
204
- })
205
- lastUpdated = next
206
- onProjectChange!(next)
207
- },
208
- onCommit: () => {
209
- onOverlayEdit?.(lastUpdated)
210
- },
211
- })
212
- }
213
-
214
- // ── Edge trim ──
215
- function handleResizeStart(e: React.MouseEvent, edge: 'start' | 'end') {
216
- if (!onProjectChange) return
217
- let lastUpdated = project
218
-
219
- const origStart = track.start
220
- const origEnd = track.end
221
- const origInPoint = track.inPoint ?? 0
222
- const origOutPoint = track.outPoint ?? (origInPoint + (origEnd - origStart))
223
- const srcDur = track.sourceDuration ?? Infinity
224
- const multiTargets = selectedIds.length > 1 && selectedIds.includes(track.id)
225
-
226
- beginResize(e, track as Draggable, edge, {
227
- onLivePreview: ({ item: resized }: DragEventContext) => {
228
- let newInPoint = origInPoint
229
- let newOutPoint = origOutPoint
230
-
231
- if (edge === 'start') {
232
- const dt = resized.start - origStart
233
- newInPoint = Math.max(0, Math.min(origInPoint + dt, origOutPoint - 0.1))
234
- } else {
235
- const dt = resized.end - origEnd
236
- newOutPoint = Math.max(origInPoint + 0.1, Math.min(origOutPoint + dt, srcDur))
237
- }
238
-
239
- let next = updateAudioTrack(project, track.id, {
240
- start: resized.start,
241
- end: resized.end,
242
- inPoint: newInPoint,
243
- outPoint: newOutPoint,
244
- })
245
- if (multiTargets) {
246
- const dStart = edge === 'start' ? resized.start - origStart : 0
247
- const dEnd = edge === 'end' ? resized.end - origEnd : 0
248
- next = applyResizeDeltaToSelection(next, track.id, selectedIds, edge, { dStart, dEnd })
249
- }
250
- lastUpdated = next
251
- onProjectChange!(next)
252
- },
253
- onCommit: () => {
254
- onOverlayEdit?.(lastUpdated)
255
- },
256
- })
257
- }
258
-
259
- // ── Mute toggle ──
260
- function handleToggleMute(e: React.MouseEvent) {
261
- e.stopPropagation()
262
- if (!onProjectChange) return
263
- const newMuted = !track.muted
264
- // Multi-selection: set every selected item to the new state. Solo: just this.
265
- const targetIds = selectedIds.length > 1 && selectedIds.includes(track.id) ? selectedIds : [track.id]
266
- const updated = applyMuteToSelection(project, targetIds, newMuted)
267
- onProjectChange(updated)
268
- onOverlayEdit?.(updated)
269
- }
270
-
271
- // ── Delete (two-step confirm) ──
272
- function handleDeleteClick(e: React.MouseEvent) {
273
- e.stopPropagation()
274
- if (confirmingDelete) {
275
- if (!onProjectChange) return
276
- const updated: Project = {
277
- ...project,
278
- audio: {
279
- ...project.audio,
280
- tracks: (project.audio?.tracks ?? []).filter(t => t.id !== track.id),
281
- },
282
- }
283
- onProjectChange(updated)
284
- onOverlayEdit?.(updated)
285
- onSelectItem(null, false)
286
- setConfirmingDelete(false)
287
- } else {
288
- setConfirmingDelete(true)
289
- if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current)
290
- deleteTimerRef.current = setTimeout(() => setConfirmingDelete(false), 3000)
291
- }
292
- }
293
-
294
- // ── Click to select ──
295
- function handleBarClick(e: React.MouseEvent) {
296
- e.stopPropagation()
297
- if (overlayDraggedRef.current) return
298
- const additive = e.shiftKey || e.metaKey || e.ctrlKey
299
- onSelectItem(track.id, additive)
300
- }
301
-
302
- // ── Double-click to open inspector ──
303
- function handleBarDoubleClick(e: React.MouseEvent) {
304
- e.stopPropagation()
305
- if (overlayDraggedRef.current) return
306
- onInspect?.(track.id)
307
- }
308
-
309
- return (
310
- <div
311
- className={`absolute top-1 bottom-1 rounded cursor-grab active:cursor-grabbing flex items-center overflow-hidden
312
- ${track.muted ? 'bg-white/10' : 'bg-emerald-500/40 border border-emerald-500/60'}
313
- ${selected ? 'ring-1 ring-inset ring-emerald-300/80' : ''}`}
314
- style={{ left: `${left}%`, width: `${width}%` }}
315
- title={label}
316
- onClick={handleBarClick}
317
- onDoubleClick={handleBarDoubleClick}
318
- onMouseDown={handleDragStart}
319
- >
320
- {/* Waveform layer */}
321
- <AudioWaveformLayer
322
- track={track}
323
- projectId={project.id}
324
- getWaveformChunks={getWaveformChunks}
325
- resolveFilePath={resolveFilePath}
326
- />
327
-
328
- {/* Fade-in gradient */}
329
- {(track.fadeIn ?? 0) > 0 && (
330
- <div
331
- className="absolute top-0 bottom-0 left-0 pointer-events-none z-[2]"
332
- style={{
333
- width: `${Math.min(100, ((track.fadeIn ?? 0) / (track.end - track.start)) * 100)}%`,
334
- background: 'linear-gradient(to right, rgba(0,0,0,0.6), transparent)',
335
- }}
336
- />
337
- )}
338
- {/* Fade-out gradient */}
339
- {(track.fadeOut ?? 0) > 0 && (
340
- <div
341
- className="absolute top-0 bottom-0 right-0 pointer-events-none z-[2]"
342
- style={{
343
- width: `${Math.min(100, ((track.fadeOut ?? 0) / (track.end - track.start)) * 100)}%`,
344
- background: 'linear-gradient(to left, rgba(0,0,0,0.6), transparent)',
345
- }}
346
- />
347
- )}
348
-
349
- {/* Left resize handle */}
350
- <div
351
- className="absolute left-0 top-0 bottom-0 w-1.5 cursor-ew-resize z-10 hover:bg-emerald-300/40"
352
- onMouseDown={(e) => handleResizeStart(e, 'start')}
353
- />
354
-
355
- {/* Mute toggle */}
356
- <button
357
- className="shrink-0 ml-2 z-10 cursor-pointer"
358
- onClick={handleToggleMute}
359
- title={track.muted ? 'Unmute' : 'Mute'}
360
- >
361
- {track.muted
362
- ? <VolumeX className="w-3.5 h-3.5 text-white/30" />
363
- : <Volume2 className="w-3.5 h-3.5 text-emerald-200" />}
364
- </button>
365
-
366
- {/* Track type label */}
367
- <span className="text-[10px] text-emerald-200 truncate flex-1 min-w-0 ml-1.5 pointer-events-none z-[1] drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]">
368
- {track.type === 'voiceover' ? 'Voiceover' : track.type === 'music' ? 'Music' : label}
369
- </span>
370
-
371
- {/* Info button when selected */}
372
- {selected && onInspect && (
373
- <button
374
- className="shrink-0 ml-1 z-10 cursor-pointer opacity-50 hover:opacity-100 text-emerald-200"
375
- onClick={(e) => { e.stopPropagation(); onInspect(track.id) }}
376
- title="Inspect audio track"
377
- >
378
- <Info size={12} />
379
- </button>
380
- )}
381
-
382
- {/* Delete button when selected (two-step confirm) */}
383
- {selected && (
384
- <button
385
- className={`shrink-0 ml-1 mr-2 z-10 cursor-pointer transition-colors ${
386
- confirmingDelete
387
- ? 'opacity-100 text-red-400'
388
- : 'opacity-60 hover:opacity-100 text-emerald-200'
389
- }`}
390
- onClick={handleDeleteClick}
391
- title={confirmingDelete ? 'Click again to confirm delete' : 'Delete audio track'}
392
- >
393
- <Trash2 size={12} />
394
- </button>
395
- )}
396
-
397
- {/* Right resize handle */}
398
- <div
399
- className="absolute right-0 top-0 bottom-0 w-1.5 cursor-ew-resize z-10 hover:bg-emerald-300/40"
400
- onMouseDown={(e) => handleResizeStart(e, 'end')}
401
- />
402
- </div>
403
- )
404
- }
@@ -1,117 +0,0 @@
1
- import { useEffect, useState } from 'react'
2
- import type { WaveformChunk } from '../../types'
3
- import type { AudioTrack } from '../../schema'
4
-
5
- /**
6
- * Fetches the waveform chunks for one audio track. Threaded from the timeline
7
- * root (VideoEditor supplies `adapter.getWaveformChunks` in V4). The package is
8
- * host-agnostic: it takes the fn as a prop rather than reaching for an adapter.
9
- */
10
- export type GetWaveformChunks = (
11
- projectId: string,
12
- trackId: string,
13
- trackSrc: string,
14
- chunkDurationS?: number,
15
- ) => Promise<WaveformChunk[]>
16
-
17
- /** Resolves a host-internal chunk path into a displayable URL (e.g. Montaj's
18
- * `/api/files?path=…`). Defaults to identity when the host omits it. */
19
- export type ResolveFilePath = (path: string) => string
20
-
21
- interface Props {
22
- track: AudioTrack
23
- projectId: string
24
- /** When absent/undefined, no waveform is rendered (graceful fallback). */
25
- getWaveformChunks?: GetWaveformChunks
26
- resolveFilePath?: ResolveFilePath
27
- }
28
-
29
- function LoadingBar() {
30
- return (
31
- <div className="relative w-full h-full overflow-hidden rounded bg-emerald-500/20">
32
- <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-pulse" />
33
- </div>
34
- )
35
- }
36
-
37
- function PlainFallback() {
38
- return <div className="w-full h-full rounded bg-emerald-500/40 border border-emerald-500/60" />
39
- }
40
-
41
- export default function AudioWaveformLayer({ track, projectId, getWaveformChunks, resolveFilePath }: Props) {
42
- const [chunks, setChunks] = useState<WaveformChunk[] | null>(null)
43
- const [error, setError] = useState(false)
44
-
45
- useEffect(() => {
46
- if (!getWaveformChunks) return
47
- let cancelled = false
48
- setChunks(null)
49
- setError(false)
50
-
51
- getWaveformChunks(projectId, track.id, track.src)
52
- .then((result) => {
53
- if (!cancelled) setChunks(result)
54
- })
55
- .catch(() => {
56
- if (!cancelled) setError(true)
57
- })
58
-
59
- return () => {
60
- cancelled = true
61
- }
62
- }, [track.id, track.src, projectId, getWaveformChunks])
63
-
64
- const isMuted = track.muted ?? false
65
-
66
- // No fetcher supplied → render nothing (graceful: the bar shows its base color).
67
- if (!getWaveformChunks) return null
68
-
69
- const resolvePath = resolveFilePath ?? ((p: string) => p)
70
-
71
- const content = (() => {
72
- if (error) return <PlainFallback />
73
- if (!chunks) return <LoadingBar />
74
-
75
- const sourceDur = track.sourceDuration ?? (track.end - track.start)
76
- const inPt = track.inPoint ?? 0
77
- const outPt = track.outPoint ?? sourceDur
78
- const visibleSpan = outPt - inPt
79
-
80
- if (visibleSpan <= 0) return <PlainFallback />
81
-
82
- // Filter to chunks that overlap [inPt, outPt]
83
- const visible = chunks.filter((c) => c.end > inPt && c.start < outPt)
84
-
85
- return (
86
- <div className="relative w-full h-full overflow-hidden rounded">
87
- {visible.map((chunk) => {
88
- const leftPct = ((chunk.start - inPt) / visibleSpan) * 100
89
- const widthPct = ((chunk.end - chunk.start) / visibleSpan) * 100
90
-
91
- return (
92
- <img
93
- key={chunk.path}
94
- src={resolvePath(chunk.path)}
95
- alt=""
96
- draggable={false}
97
- className="absolute top-0 h-full object-fill"
98
- style={{
99
- left: `${leftPct}%`,
100
- width: `${widthPct}%`,
101
- }}
102
- />
103
- )
104
- })}
105
- </div>
106
- )
107
- })()
108
-
109
- return (
110
- <div
111
- className="absolute inset-0 pointer-events-none"
112
- style={{ opacity: isMuted ? 0.3 : 1 }}
113
- >
114
- {content}
115
- </div>
116
- )
117
- }