@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
@@ -1,300 +0,0 @@
1
- import { memo } from 'react'
2
- import { Volume2, VolumeX, Info, Scissors, Pencil } from 'lucide-react'
3
- import type { VisualItem } from '../../schema'
4
- import type { Project } from '../../types'
5
- import { collapseGaps } from '../cuts'
6
- import { pct, ratioFromClientX, trackRow, trackRowTall } from './utils'
7
- import { useTimelineContext } from './TimelineContext'
8
- import PlayheadLine from './PlayheadLine'
9
- import { useItemDragDrop } from './useItemDragDrop'
10
- import type { Draggable, DragEventContext } from './useItemDragDrop'
11
- import { applyMuteToSelection, applyResizeDeltaToSelection, deleteSelection } from './multiSelectOps'
12
-
13
- interface VisualTrackRowProps {
14
- trackItems: VisualItem[]
15
- trackIdx: number
16
- project: Project
17
- /** Unified multi-selection. First entry is the primary; isSel checks membership. */
18
- selectedIds: string[]
19
- rippleMode: boolean
20
- onProjectChange?: (p: Project) => void
21
- onOverlayEdit?: (p: Project) => void
22
- /** Open the overlay props dialog for an overlay item (VideoEditor owns it). */
23
- onEditOverlay?: (id: string) => void
24
- /** Click handler — additive when shift/meta is held. */
25
- onSelectItem: (id: string | null, additive: boolean) => void
26
- onInspectClip?: (id: string) => void
27
- subcutClipId: string | null
28
- setSubcutClipId: (id: string | null) => void
29
- /** Host-computed gate for the subcut-regenerate affordance (Montaj: project
30
- * is an ai_video). When false/undefined the Scissors button is hidden — the
31
- * package stays agnostic of Montaj's projectType. */
32
- regenEnabled?: boolean
33
- /** Host-computed predicate: is this clip already queued for regeneration?
34
- * (Montaj: project.regenQueue has an entry for this clipId.) Drives the
35
- * "queued" badge. */
36
- isClipQueued?: (itemId: string) => boolean
37
- }
38
-
39
- const trackColors = [
40
- { bg: 'bg-slate-600/80', bgHov: 'hover:bg-slate-500/80', bgSel: 'bg-slate-500/90', ring: 'ring-slate-300/80', border: 'border-slate-400/50', text: 'text-slate-200', resHov: 'hover:bg-slate-300/40' },
41
- { bg: 'bg-sky-700/80', bgHov: 'hover:bg-sky-600/80', bgSel: 'bg-sky-600/90', ring: 'ring-sky-300/80', border: 'border-sky-400/50', text: 'text-sky-200', resHov: 'hover:bg-sky-300/40' },
42
- { bg: 'bg-violet-700/80', bgHov: 'hover:bg-violet-600/80', bgSel: 'bg-violet-600/90', ring: 'ring-violet-300/80', border: 'border-violet-400/50', text: 'text-violet-200', resHov: 'hover:bg-violet-300/40' },
43
- { bg: 'bg-emerald-700/80',bgHov: 'hover:bg-emerald-600/80',bgSel: 'bg-emerald-600/90',ring: 'ring-emerald-300/80',border: 'border-emerald-400/50',text: 'text-emerald-200',resHov: 'hover:bg-emerald-300/40'},
44
- { bg: 'bg-rose-700/80', bgHov: 'hover:bg-rose-600/80', bgSel: 'bg-rose-600/90', ring: 'ring-rose-300/80', border: 'border-rose-400/50', text: 'text-rose-200', resHov: 'hover:bg-rose-300/40' },
45
- { bg: 'bg-amber-700/60', bgHov: 'hover:bg-amber-700/80', bgSel: 'bg-amber-600/80', ring: 'ring-amber-400/80', border: 'border-amber-500/50', text: 'text-amber-200', resHov: 'hover:bg-amber-300/40' },
46
- ]
47
-
48
- function VisualTrackRow({
49
- trackItems,
50
- trackIdx,
51
- project,
52
- selectedIds,
53
- rippleMode,
54
- onProjectChange,
55
- onOverlayEdit,
56
- onEditOverlay,
57
- onSelectItem,
58
- onInspectClip,
59
- subcutClipId,
60
- setSubcutClipId,
61
- regenEnabled,
62
- isClipQueued,
63
- }: VisualTrackRowProps) {
64
- const { totalDuration, snapBoundaries, scrollRef, scrubberRef, clock, markers, setMarkers, selection, overlayDraggedRef, zoomRef } = useTimelineContext()
65
- const tc = trackColors[trackIdx % trackColors.length]
66
- const markerActive = markers[0] !== null || selection !== null
67
- const primarySelectedId = selectedIds[0] ?? null
68
- const dimmed = markerActive && primarySelectedId !== null && !trackItems.some(i => i.id === primarySelectedId)
69
-
70
- const { beginDrag, beginResize } = useItemDragDrop({
71
- totalDuration,
72
- snapBoundaries,
73
- scrollRef,
74
- zoomRef,
75
- draggedFlagRef: overlayDraggedRef,
76
- })
77
-
78
- function handleItemResizeStart(e: React.MouseEvent, item: VisualItem, edge: 'start' | 'end') {
79
- if (!onProjectChange) return
80
- let lastUpdated = project
81
- const origStart = item.start
82
- const origEnd = item.end
83
- const multiTargets = selectedIds.length > 1 && selectedIds.includes(item.id)
84
-
85
- beginResize(e, item as Draggable, edge, {
86
- onLivePreview: ({ item: resized }: DragEventContext) => {
87
- // First: apply the hook's resize (which has clamping, snap, in/outPoint
88
- // math) to the originator clip.
89
- let next: Project = {
90
- ...project,
91
- tracks: (project.tracks ?? []).map(track =>
92
- track.map(ov => ov.id !== item.id ? ov : { ...ov, start: resized.start, end: resized.end, inPoint: resized.inPoint, outPoint: resized.outPoint })
93
- ),
94
- }
95
- // Then: propagate the same delta to every other selected item across
96
- // visual + audio tracks.
97
- if (multiTargets) {
98
- const dStart = edge === 'start' ? resized.start - origStart : 0
99
- const dEnd = edge === 'end' ? resized.end - origEnd : 0
100
- next = applyResizeDeltaToSelection(next, item.id, selectedIds, edge, { dStart, dEnd })
101
- }
102
- if (rippleMode) next = collapseGaps(next)
103
- lastUpdated = next
104
- onProjectChange!(next)
105
- },
106
- onCommit: () => {
107
- onOverlayEdit?.(lastUpdated)
108
- },
109
- })
110
- }
111
-
112
- function handleDeleteOverlay(id: string) {
113
- if (!onProjectChange) return
114
- // If part of a multi-selection, delete the whole selection; otherwise just
115
- // this item. Matches the Delete-key behavior in Timeline.handleKeyDown.
116
- const ids = selectedIds.length > 1 && selectedIds.includes(id) ? selectedIds : [id]
117
- let updated = deleteSelection(project, ids)
118
- if (rippleMode) updated = collapseGaps(updated)
119
- onProjectChange(updated)
120
- onOverlayEdit?.(updated)
121
- onSelectItem(null, false)
122
- }
123
-
124
- function handleToggleMute(item: VisualItem) {
125
- if (!onProjectChange) return
126
- const newMuted = !item.muted
127
- // If clicked item is in a multi-selection, set every selected item's mute
128
- // state to the new target (uniform end state, no surprise mixed state).
129
- const targetIds = selectedIds.length > 1 && selectedIds.includes(item.id) ? selectedIds : [item.id]
130
- const updated = applyMuteToSelection(project, targetIds, newMuted)
131
- onProjectChange(updated)
132
- onOverlayEdit?.(updated)
133
- }
134
-
135
- function handleOverlayDragStart(e: React.MouseEvent, item: VisualItem, sourceTrackIdx: number) {
136
- if ((e.target as HTMLElement).classList.contains('cursor-ew-resize')) return
137
- if (!onProjectChange) return
138
- const projectChange = onProjectChange
139
- const ROW_HEIGHT_PX = 24
140
- let lastUpdated = project
141
-
142
- beginDrag(e, item as Draggable, {
143
- onLivePreview: ({ item: moved, dy }: DragEventContext) => {
144
- const trackDelta = Math.round(dy / ROW_HEIGHT_PX)
145
- const targetIdx = Math.max(0, sourceTrackIdx - trackDelta)
146
- const duration = moved.end - moved.start
147
- const overlapMin = duration * 0.3
148
-
149
- function hasOverlap(track: VisualItem[]): boolean {
150
- return track.some(ov => {
151
- if (ov.id === item.id) return false
152
- return Math.min(moved.end, ov.end) - Math.max(moved.start, ov.start) > overlapMin
153
- })
154
- }
155
-
156
- const tracks = lastUpdated.tracks ?? []
157
- let bestIdx = targetIdx
158
- outer: for (let delta = 0; delta <= tracks.length; delta++) {
159
- for (const i of delta === 0 ? [targetIdx] : [targetIdx - delta, targetIdx + delta]) {
160
- if (i < 0) continue
161
- const candidateTrack = i < tracks.length ? tracks[i] : []
162
- if (!hasOverlap(candidateTrack)) { bestIdx = i; break outer }
163
- }
164
- }
165
-
166
- const removed = tracks.map(t => t.filter(ov => ov.id !== item.id))
167
- const movedItem = { ...item, start: moved.start, end: moved.end }
168
- const final = bestIdx >= removed.length
169
- ? [...removed, [movedItem]]
170
- : removed.map((t, i) => i === bestIdx ? [...t, movedItem] : t)
171
-
172
- const next = { ...lastUpdated, tracks: final.filter(t => t.length > 0) }
173
- projectChange(next)
174
- lastUpdated = next
175
- },
176
- onCommit: () => {
177
- onOverlayEdit?.(lastUpdated)
178
- },
179
- })
180
- }
181
-
182
- function handleScrubDoubleClick(e: React.MouseEvent<HTMLDivElement>) {
183
- if (totalDuration === 0) return
184
- e.preventDefault()
185
- const t = ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
186
- setMarkers(([a, b]) => {
187
- if (a === null) return [t, null] // place first marker
188
- if (b === null) return [a, t] // place second → selection complete
189
- return [t, null] // reset: start fresh with new first marker
190
- })
191
- }
192
-
193
- function handleTrackClick(e: React.MouseEvent) {
194
- e.stopPropagation()
195
- if (totalDuration === 0) return
196
- const clickedTime = ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration
197
- const rect = scrubberRef.current?.getBoundingClientRect()
198
- const snapThreshold = rect ? (8 / rect.width) * totalDuration : 0
199
- const boundaries = snapBoundaries
200
- for (const b of boundaries) {
201
- if (Math.abs(clickedTime - b) < snapThreshold) { clock.set(b); return }
202
- }
203
- clock.set(clickedTime)
204
- }
205
-
206
- return (
207
- <div className={`${trackIdx === 0 ? trackRowTall : trackRow} transition-opacity ${dimmed ? 'opacity-30 pointer-events-none' : ''}`} onClick={handleTrackClick} onDoubleClick={handleScrubDoubleClick}>
208
- {trackItems.map((item) => {
209
- const isSel = selectedIds.includes(item.id)
210
- return (
211
- <div
212
- key={item.id}
213
- className={`absolute top-0 bottom-0 flex items-center overflow-hidden cursor-grab active:cursor-grabbing
214
- ${isSel ? `${tc.bgSel} ring-1 ring-inset ${tc.ring}` : `${tc.bg} ${tc.bgHov}`}
215
- border-r ${tc.border}`}
216
- style={{ left: `${pct(item.start, totalDuration)}%`, width: `${pct(item.end - item.start, totalDuration)}%` }}
217
- onClick={(e) => {
218
- e.stopPropagation()
219
- if (overlayDraggedRef.current) return
220
- const additive = e.shiftKey || e.metaKey || e.ctrlKey
221
- onSelectItem(item.id, additive)
222
- // Only seek playhead on a plain single-select click (not on
223
- // additive shift-clicks, which shouldn't disrupt scrubbing).
224
- if (!additive && !isSel) clock.set(ratioFromClientX(e.clientX, scrubberRef.current!.getBoundingClientRect()) * totalDuration)
225
- }}
226
- onDoubleClick={(e) => {
227
- e.stopPropagation()
228
- if (onInspectClip) onInspectClip(item.id)
229
- }}
230
- onMouseDown={(e) => handleOverlayDragStart(e, item, trackIdx)}
231
- >
232
- <div
233
- className={`absolute left-0 top-0 bottom-0 w-2.5 cursor-ew-resize z-10 ${tc.resHov}`}
234
- onMouseDown={(e) => handleItemResizeStart(e, item, 'start')}
235
- />
236
- <span className={`text-[10px] ${tc.text} truncate flex-1 min-w-0 pl-3`}>
237
- ▪ {item.type}
238
- {project.renderMode === 'ffmpeg-drawtext' && trackIdx > 0 && (
239
- <span className="ml-1.5 text-amber-400/60">preview</span>
240
- )}
241
- {isClipQueued?.(item.id) && (
242
- <span className="ml-1.5 text-amber-300/80 font-medium">queued</span>
243
- )}
244
- </span>
245
- {item.type === 'video' && (
246
- <button
247
- className={`shrink-0 mr-3 z-10 cursor-pointer transition-opacity ${item.muted ? 'opacity-30 hover:opacity-60' : 'opacity-50 hover:opacity-90'} ${tc.text}`}
248
- onClick={(e) => { e.stopPropagation(); handleToggleMute(item) }}
249
- title={item.muted ? 'Unmute' : 'Mute'}
250
- >
251
- {item.muted ? <VolumeX size={10} /> : <Volume2 size={10} />}
252
- </button>
253
- )}
254
- {isSel && onInspectClip && item.type === 'video' && (
255
- <button
256
- className={`shrink-0 ml-1 z-10 cursor-pointer opacity-50 hover:opacity-100 ${tc.text}`}
257
- onClick={(e) => { e.stopPropagation(); onInspectClip(item.id) }}
258
- title="Inspect generation"
259
- ><Info size={10} /></button>
260
- )}
261
- {isSel && regenEnabled && item.generation && (item.end - item.start) >= 3 && (
262
- <button
263
- className={`shrink-0 ml-1 z-10 cursor-pointer opacity-50 hover:opacity-100 ${tc.text}`}
264
- onClick={(e) => { e.stopPropagation(); setSubcutClipId(subcutClipId === item.id ? null : item.id) }}
265
- title="Subcut regenerate"
266
- ><Scissors size={10} /></button>
267
- )}
268
- {isSel && onEditOverlay && item.type === 'overlay' && !!item.src && (
269
- <button
270
- className={`shrink-0 ml-1 z-10 cursor-pointer opacity-60 hover:opacity-100 ${tc.text}`}
271
- onClick={(e) => { e.stopPropagation(); onEditOverlay(item.id) }}
272
- title="Edit overlay"
273
- ><Pencil size={10} /></button>
274
- )}
275
- {isSel && (
276
- <button
277
- className={`shrink-0 ml-1 mr-3 z-10 cursor-pointer opacity-60 hover:opacity-100 ${tc.text} text-[11px] leading-none`}
278
- onClick={(e) => { e.stopPropagation(); handleDeleteOverlay(item.id) }}
279
- title="Delete"
280
- >×</button>
281
- )}
282
- <div
283
- className={`absolute right-0 top-0 bottom-0 w-2.5 cursor-ew-resize z-10 ${tc.resHov}`}
284
- onMouseDown={(e) => handleItemResizeStart(e, item, 'end')}
285
- />
286
- </div>
287
- )
288
- })}
289
- <PlayheadLine />
290
- {selection && (
291
- <div
292
- className="absolute inset-y-0 bg-red-500/20 pointer-events-none"
293
- style={{ left: `${pct(selection.start, totalDuration)}%`, width: `${pct(selection.end - selection.start, totalDuration)}%` }}
294
- />
295
- )}
296
- </div>
297
- )
298
- }
299
-
300
- export default memo(VisualTrackRow)
@@ -1,241 +0,0 @@
1
- /// <reference types="vitest/globals" />
2
- import { render, screen, fireEvent } from '@testing-library/react'
3
- import { createPlaybackClock } from '../../playback-clock'
4
- import { TimelineContext, type TimelineContextValue } from '../TimelineContext'
5
- import CaptionTrackRow from '../CaptionTrackRow'
6
- import type { CaptionSegment } from '../../../schema'
7
-
8
- // Content width matches totalDuration 1:1 (1000px / 100s) so a clientX delta
9
- // of N px is exactly N/10 seconds — makes the drag-math assertions readable.
10
- const RECT = {
11
- width: 1000, height: 40, left: 0, top: 0, right: 1000, bottom: 40, x: 0, y: 0,
12
- toJSON: () => ({}),
13
- } as DOMRect
14
-
15
- function makeCtx(overrides: Partial<TimelineContextValue> = {}): TimelineContextValue {
16
- return {
17
- totalDuration: 100,
18
- contentDuration: 100,
19
- snapBoundaries: [],
20
- zoom: 1,
21
- zoomRef: { current: 1 },
22
- scrollRef: { current: { getBoundingClientRect: () => RECT } as unknown as HTMLDivElement },
23
- scrubberRef: { current: null },
24
- overlayDraggedRef: { current: false },
25
- clock: createPlaybackClock(),
26
- markers: [null, null],
27
- setMarkers: () => {},
28
- selection: null,
29
- ...overrides,
30
- }
31
- }
32
-
33
- const seg = (over: Partial<CaptionSegment> = {}): CaptionSegment => ({
34
- id: 'cap-0', text: 'hello world', start: 10, end: 20, ...over,
35
- })
36
-
37
- test('empty state renders when captionTrack is absent', () => {
38
- render(
39
- <TimelineContext.Provider value={makeCtx()}>
40
- <CaptionTrackRow captionTrack={undefined} fps={30} selectedCaptionId={null} />
41
- </TimelineContext.Provider>,
42
- )
43
- expect(screen.getByText('Captions')).toBeTruthy()
44
- })
45
-
46
- test('empty state renders when captionTrack has zero segments', () => {
47
- render(
48
- <TimelineContext.Provider value={makeCtx()}>
49
- <CaptionTrackRow captionTrack={{ style: 'clean', segments: [] }} fps={30} selectedCaptionId={null} />
50
- </TimelineContext.Provider>,
51
- )
52
- expect(screen.getByText('Captions')).toBeTruthy()
53
- })
54
-
55
- test('clicking a segment selects it and seeks the playhead into it', () => {
56
- const ctx = makeCtx()
57
- const onSelectCaption = vi.fn()
58
- render(
59
- <TimelineContext.Provider value={ctx}>
60
- <CaptionTrackRow
61
- captionTrack={{ style: 'clean', segments: [seg()] }}
62
- fps={30}
63
- selectedCaptionId={null}
64
- onSelectCaption={onSelectCaption}
65
- />
66
- </TimelineContext.Provider>,
67
- )
68
- // The row itself (trackRow) also carries `cursor-pointer`, so anchor off the
69
- // segment's text and walk up to its block instead of querying the class.
70
- const block = screen.getByText('hello world').parentElement as HTMLDivElement
71
- fireEvent.click(block)
72
- expect(onSelectCaption).toHaveBeenCalledWith('cap-0')
73
- expect(ctx.clock.get()).toBeCloseTo(10 + 0.5 / 30)
74
- })
75
-
76
- // Regression guard: the seek must survive CaptionPreview's frame quantization.
77
- // The preview computes `t = Math.round(currentTime * fps) / fps` before running
78
- // the templates' `t >= start && t < end` test, so seeking to a `start` that is
79
- // not frame-aligned used to round DOWN into the PREVIOUS segment — the operator
80
- // clicked a caption in the timeline and the preview drew its box over the one
81
- // before it. Whisper emits arbitrary floats, so this hit about half of all
82
- // segments.
83
- test('the seek lands inside the clicked segment even when its start is not frame-aligned', () => {
84
- const fps = 30
85
- // 3.44 * 30 = 103.2 → frame 103 → t = 3.4333, which is BEFORE 3.44.
86
- const segments: CaptionSegment[] = [
87
- { id: 'cap-0', text: 'first', start: 0, end: 3.44 },
88
- { id: 'cap-1', text: 'second', start: 3.44, end: 6.02 },
89
- ]
90
- const ctx = makeCtx()
91
- render(
92
- <TimelineContext.Provider value={ctx}>
93
- <CaptionTrackRow
94
- captionTrack={{ style: 'clean', segments }}
95
- fps={fps}
96
- selectedCaptionId={null}
97
- onSelectCaption={vi.fn()}
98
- />
99
- </TimelineContext.Provider>,
100
- )
101
- fireEvent.click(screen.getByText('second').parentElement as HTMLDivElement)
102
-
103
- const t = Math.round(ctx.clock.get() * fps) / fps
104
- const active = segments.find((s) => t >= s.start && t < s.end)
105
- expect(active?.id).toBe('cap-1')
106
- })
107
-
108
- // Regression guard: `steps/lyrics/caption.py` emits segments with no `id`
109
- // field at all — ids are only minted later by VideoEditor's
110
- // backfillCaptionIds effect. On first render `live` is null, so `live?.id`
111
- // is undefined; an id-less segment's `seg.id` is also undefined, and
112
- // undefined === undefined used to make `isLive` true and dereference `live!`
113
- // while it was still null. This is the normal shape of every real project on
114
- // its first paint, not an edge case.
115
- test('renders without throwing when segments have no id field (pre-backfill shape)', () => {
116
- const segments = [
117
- { text: 'hello world', start: 10, end: 20 },
118
- ] as unknown as CaptionSegment[]
119
- expect(() =>
120
- render(
121
- <TimelineContext.Provider value={makeCtx()}>
122
- <CaptionTrackRow
123
- captionTrack={{ style: 'clean', segments }}
124
- fps={30}
125
- selectedCaptionId={null}
126
- />
127
- </TimelineContext.Provider>,
128
- ),
129
- ).not.toThrow()
130
- })
131
-
132
- describe('edge-drag retiming', () => {
133
- // Regression guard for the single easiest way to break this feature: a
134
- // retime patch must carry ONLY the dragged edge. If `text` ever leaked in,
135
- // makeCaptionEdit would respread word timings and destroy per-word timing.
136
- it('dragging the right edge sends { end } only — never text/words/other fields', () => {
137
- const ctx = makeCtx()
138
- const onCaptionSegmentChange = vi.fn()
139
- const { container } = render(
140
- <TimelineContext.Provider value={ctx}>
141
- <CaptionTrackRow
142
- captionTrack={{ style: 'clean', segments: [seg()] }}
143
- fps={30}
144
- selectedCaptionId={null}
145
- onCaptionSegmentChange={onCaptionSegmentChange}
146
- />
147
- </TimelineContext.Provider>,
148
- )
149
- const handles = container.querySelectorAll('.cursor-ew-resize')
150
- const rightHandle = handles[1] as HTMLDivElement
151
-
152
- fireEvent.mouseDown(rightHandle, { clientX: 500 })
153
- fireEvent.mouseMove(document, { clientX: 600 }) // +100px = +10s
154
- // Not committed until mouseup — retiming must not fire on every tick
155
- // (unlike VisualTrackRow, onCaptionSegmentChange pushes an undo entry and
156
- // queues a save PER CALL).
157
- expect(onCaptionSegmentChange).not.toHaveBeenCalled()
158
-
159
- fireEvent.mouseUp(document)
160
- expect(onCaptionSegmentChange).toHaveBeenCalledTimes(1)
161
- const [id, patch] = onCaptionSegmentChange.mock.calls[0]
162
- expect(id).toBe('cap-0')
163
- expect(patch).toEqual({ end: 30 })
164
- expect(Object.keys(patch)).toEqual(['end'])
165
- })
166
-
167
- // `useItemDragDrop.beginResize` has no travel threshold (unlike `beginDrag`)
168
- // and calls onCommit unconditionally on mouseup, so a bare click on the 6px
169
- // edge handle used to commit the edge's unchanged value — one undo entry and
170
- // one queued save for a project that did not change.
171
- it('a press-and-release on an edge handle with no movement commits nothing', () => {
172
- const ctx = makeCtx()
173
- const onCaptionSegmentChange = vi.fn()
174
- const { container } = render(
175
- <TimelineContext.Provider value={ctx}>
176
- <CaptionTrackRow
177
- captionTrack={{ style: 'clean', segments: [seg()] }}
178
- fps={30}
179
- selectedCaptionId={null}
180
- onCaptionSegmentChange={onCaptionSegmentChange}
181
- />
182
- </TimelineContext.Provider>,
183
- )
184
- const rightHandle = container.querySelectorAll('.cursor-ew-resize')[1] as HTMLDivElement
185
-
186
- fireEvent.mouseDown(rightHandle, { clientX: 500 })
187
- fireEvent.mouseUp(document)
188
- expect(onCaptionSegmentChange).not.toHaveBeenCalled()
189
- })
190
-
191
- // A drag that returns to where it started is likewise a no-op — the edge
192
- // value is what matters, not whether the pointer moved.
193
- it('a drag that ends back at the original edge commits nothing', () => {
194
- const ctx = makeCtx()
195
- const onCaptionSegmentChange = vi.fn()
196
- const { container } = render(
197
- <TimelineContext.Provider value={ctx}>
198
- <CaptionTrackRow
199
- captionTrack={{ style: 'clean', segments: [seg()] }}
200
- fps={30}
201
- selectedCaptionId={null}
202
- onCaptionSegmentChange={onCaptionSegmentChange}
203
- />
204
- </TimelineContext.Provider>,
205
- )
206
- const rightHandle = container.querySelectorAll('.cursor-ew-resize')[1] as HTMLDivElement
207
-
208
- fireEvent.mouseDown(rightHandle, { clientX: 500 })
209
- fireEvent.mouseMove(document, { clientX: 600 })
210
- fireEvent.mouseMove(document, { clientX: 500 })
211
- fireEvent.mouseUp(document)
212
- expect(onCaptionSegmentChange).not.toHaveBeenCalled()
213
- })
214
-
215
- it('dragging the left edge sends { start } only', () => {
216
- const ctx = makeCtx()
217
- const onCaptionSegmentChange = vi.fn()
218
- const { container } = render(
219
- <TimelineContext.Provider value={ctx}>
220
- <CaptionTrackRow
221
- captionTrack={{ style: 'clean', segments: [seg()] }}
222
- fps={30}
223
- selectedCaptionId={null}
224
- onCaptionSegmentChange={onCaptionSegmentChange}
225
- />
226
- </TimelineContext.Provider>,
227
- )
228
- const handles = container.querySelectorAll('.cursor-ew-resize')
229
- const leftHandle = handles[0] as HTMLDivElement
230
-
231
- fireEvent.mouseDown(leftHandle, { clientX: 500 })
232
- fireEvent.mouseMove(document, { clientX: 400 }) // -100px = -10s
233
- fireEvent.mouseUp(document)
234
-
235
- expect(onCaptionSegmentChange).toHaveBeenCalledTimes(1)
236
- const [id, patch] = onCaptionSegmentChange.mock.calls[0]
237
- expect(id).toBe('cap-0')
238
- expect(patch).toEqual({ start: 0 })
239
- expect(Object.keys(patch)).toEqual(['start'])
240
- })
241
- })
@@ -1,60 +0,0 @@
1
- /// <reference types="vitest/globals" />
2
- import { render, act } from '@testing-library/react'
3
- import { createPlaybackClock } from '../../playback-clock'
4
- import { TimelineContext, type TimelineContextValue } from '../TimelineContext'
5
- import PlayheadLine from '../PlayheadLine'
6
-
7
- function makeCtx(overrides: Partial<TimelineContextValue> = {}): TimelineContextValue {
8
- return {
9
- totalDuration: 10,
10
- contentDuration: 10,
11
- snapBoundaries: [],
12
- zoom: 1,
13
- zoomRef: { current: 1 },
14
- scrollRef: { current: null },
15
- scrubberRef: { current: null },
16
- overlayDraggedRef: { current: false },
17
- clock: createPlaybackClock(),
18
- markers: [null, null],
19
- setMarkers: () => {},
20
- selection: null,
21
- ...overrides,
22
- }
23
- }
24
-
25
- test('PlayheadLine moves on clock.set without re-rendering a sibling row', () => {
26
- const ctx = makeCtx()
27
- let siblingRenders = 0
28
-
29
- function Sibling() {
30
- siblingRenders++
31
- return null
32
- }
33
- function Root() {
34
- return (
35
- <TimelineContext.Provider value={ctx}>
36
- <PlayheadLine />
37
- <Sibling />
38
- </TimelineContext.Provider>
39
- )
40
- }
41
-
42
- const { container } = render(<Root />)
43
- const before = siblingRenders
44
-
45
- act(() => { ctx.clock.set(4) })
46
-
47
- const line = container.querySelector('div') as HTMLDivElement
48
- expect(line.style.left).toBe('40%')
49
- expect(siblingRenders).toBe(before)
50
- })
51
-
52
- test('renders nothing when totalDuration is 0', () => {
53
- const ctx = makeCtx({ totalDuration: 0 })
54
- const { container } = render(
55
- <TimelineContext.Provider value={ctx}>
56
- <PlayheadLine />
57
- </TimelineContext.Provider>,
58
- )
59
- expect(container.firstChild).toBeNull()
60
- })
@@ -1,41 +0,0 @@
1
- import { describe, it, expect, vi, afterEach } from 'vitest'
2
- import { render, screen, fireEvent, cleanup } from '@testing-library/react'
3
- import type { Project } from '../../../types'
4
- import type { CaptionSegment } from '../../../schema'
5
- import TranscriptModal from '../TranscriptModal'
6
-
7
- afterEach(() => cleanup())
8
-
9
- function makeProject(segments: CaptionSegment[]): Project {
10
- return { id: 'p1', captions: { style: 'word-by-word', segments } } as unknown as Project
11
- }
12
-
13
- describe('TranscriptModal caption text edits', () => {
14
- // Regression: makeCaptionEdit fires every callback it's given with the same
15
- // updated project. TranscriptModal used to pass BOTH onProjectChange and
16
- // onCaptionEdit into makeCaptionEdit for a text edit, and — at this
17
- // component's real call site (VideoEditor's ReviewSurface, via Timeline) —
18
- // both land on sync.mutate, so one text edit produced two undo entries and
19
- // two queued saves. Only onCaptionEdit (the commit channel) should fire;
20
- // onProjectChange is no longer even an accepted prop (see TranscriptModal.tsx).
21
- it('a completed text edit produces exactly one commit, not two', () => {
22
- const onCaptionEdit = vi.fn()
23
- const project = makeProject([{ id: 'cap-0', text: 'hello world', start: 0, end: 2 }])
24
- render(
25
- <TranscriptModal
26
- captionTrack={project.captions}
27
- currentTime={0}
28
- project={project}
29
- onCaptionEdit={onCaptionEdit}
30
- onClose={() => {}}
31
- />,
32
- )
33
-
34
- const span = screen.getByText('hello world')
35
- span.textContent = 'hello there'
36
- fireEvent.blur(span)
37
-
38
- expect(onCaptionEdit).toHaveBeenCalledTimes(1)
39
- expect(onCaptionEdit.mock.calls[0][0].captions.segments[0].text).toBe('hello there')
40
- })
41
- })