@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,74 @@
1
+ /**
2
+ * captionActiveWord — derives the ONE caption word currently under the
3
+ * playhead, for the caption-styling panel's live font specimen (it shows the
4
+ * actual word from the user's video, rendered in the candidate typeface, so
5
+ * a font choice is judged against real content rather than a placeholder).
6
+ *
7
+ * DERIVED, NOT SELECTED — deliberately. The editor has no word-level
8
+ * selection: `selectedIds` (CaptionListPanel.tsx:76) is segment-level and
9
+ * shared across captions, clips, and audio. Inventing a word-selection model
10
+ * just for a font preview would mean touching that shared selection system
11
+ * for no benefit elsewhere. The playhead already tells us which word the
12
+ * user is looking at, so this module reads it from there instead.
13
+ *
14
+ * The active-segment test mirrors two existing call sites exactly rather than
15
+ * inventing a third notion of "active": the half-open `currentTime >= start
16
+ * && currentTime < end` predicate is `CaptionListPanel`'s row-highlight test
17
+ * and the caption render templates' `activeSegments` test. Segments can be
18
+ * simultaneously active across different lanes (`seg.lane`, see
19
+ * `captionLanes.ts`); when more than one is active this picks the operator's
20
+ * selection if it's among them, else the highest lane — the caption painted
21
+ * last and therefore on top — mirroring the target-segment pick in
22
+ * `preview/CaptionPreview.tsx`.
23
+ */
24
+ import type { CaptionSegment, Captions } from '../schema'
25
+ import { laneOf } from './captionLanes'
26
+
27
+ export interface ActiveCaptionWord {
28
+ word: string
29
+ segmentId?: string
30
+ }
31
+
32
+ const isSegmentActive = (seg: CaptionSegment, t: number): boolean => t >= seg.start && t < seg.end
33
+
34
+ /** Non-empty after trimming, else `undefined` — the shared guard behind the
35
+ * "never return an empty-string word" rule: a hand-authored segment can have
36
+ * blank `text` and no `words`, and that must read as "no word", not `''`. */
37
+ function nonEmpty(s: string | undefined): string | undefined {
38
+ const trimmed = s?.trim()
39
+ return trimmed ? s : undefined
40
+ }
41
+
42
+ /** A segment's own first word, for when the playhead doesn't land inside any
43
+ * `words[]` span: the segment's first transcribed word if it has one,
44
+ * else the first whitespace-separated token of its `text` (segments authored
45
+ * without word-level timing, e.g. non-animated styles). */
46
+ function firstWordOfSegment(seg: CaptionSegment): string | undefined {
47
+ return nonEmpty(seg.words?.[0]?.word) ?? nonEmpty(seg.text?.trim().split(/\s+/)[0])
48
+ }
49
+
50
+ export function activeCaptionWord(
51
+ captions: Captions | undefined,
52
+ currentTime: number,
53
+ selectedSegmentId?: string,
54
+ ): ActiveCaptionWord | null {
55
+ const segments = captions?.segments ?? []
56
+ const activeSegs = segments.filter((seg) => isSegmentActive(seg, currentTime))
57
+
58
+ let target: CaptionSegment | undefined
59
+ if (activeSegs.length > 0) {
60
+ target =
61
+ (selectedSegmentId ? activeSegs.find((seg) => seg.id === selectedSegmentId) : undefined) ??
62
+ activeSegs.reduce((top, seg) => (laneOf(seg) > laneOf(top) ? seg : top))
63
+ } else if (selectedSegmentId) {
64
+ target = segments.find((seg) => seg.id === selectedSegmentId)
65
+ }
66
+
67
+ if (!target) return null
68
+
69
+ const spanningWord = target.words?.find((word) => currentTime >= word.start && currentTime < word.end)
70
+ const word = nonEmpty(spanningWord?.word) ?? firstWordOfSegment(target)
71
+ if (!word) return null
72
+
73
+ return { word, segmentId: target.id }
74
+ }
@@ -0,0 +1,202 @@
1
+ import type { CaptionSegment, Captions } from '../schema'
2
+
3
+ /**
4
+ * Caption lanes — pure helpers over `CaptionSegment[]` / `Captions`. Every
5
+ * consumer that needs to know which lane a caption sits in, how lanes group
6
+ * up, or where a dragged caption should land reads it through this module, so
7
+ * lane semantics can't drift between the canvas painter, the caption
8
+ * sidebar, and the drag gesture that later phases build on top of this.
9
+ *
10
+ * See the `lane` field doc on `CaptionSegment` (schema.ts) for why caption
11
+ * lanes deliberately do NOT auto-increment the way `AudioTrack.lane` /
12
+ * `groupAudioLanes` do: absent means lane 0, full stop.
13
+ */
14
+
15
+ // ── Reading a segment's lane ────────────────────────────────────────────────
16
+
17
+ /**
18
+ * A segment's lane, defaulted and defensively coerced. Absent, negative,
19
+ * non-integer, or non-finite (`NaN`/`Infinity`) stored values all collapse to
20
+ * a safe in-range integer rather than propagating into array indexing or
21
+ * comparisons downstream — a hand-edited project.json is the realistic source
22
+ * of a bad value here, not this codebase.
23
+ */
24
+ export function laneOf(seg: CaptionSegment): number {
25
+ const raw = seg.lane
26
+ if (raw == null || !Number.isFinite(raw)) return 0
27
+ const n = Math.trunc(raw)
28
+ return n < 0 ? 0 : n
29
+ }
30
+
31
+ /** Highest lane index present across `segments`, or 0 for an empty/lane-less
32
+ * track — so callers can size a `0..maxCaptionLane` structure without
33
+ * special-casing "no captions yet". */
34
+ export function maxCaptionLane(segments: CaptionSegment[]): number {
35
+ return segments.reduce((max, seg) => Math.max(max, laneOf(seg)), 0)
36
+ }
37
+
38
+ // ── Grouping ─────────────────────────────────────────────────────────────
39
+
40
+ export interface CaptionLaneGroup {
41
+ lane: number
42
+ segments: CaptionSegment[]
43
+ }
44
+
45
+ /**
46
+ * Group `segments` into one entry per lane, `0..maxCaptionLane(segments)`,
47
+ * INCLUDING holes as empty groups (`segments: []`). A hole must be
48
+ * representable, not just skipped, because a later phase renders one row per
49
+ * lane and needs a visible, drop-targetable empty band mid-drag even where no
50
+ * caption currently lives.
51
+ */
52
+ export function groupCaptionLanes(segments: CaptionSegment[]): CaptionLaneGroup[] {
53
+ const groups: CaptionLaneGroup[] = Array.from(
54
+ { length: maxCaptionLane(segments) + 1 },
55
+ (_, lane) => ({ lane, segments: [] }),
56
+ )
57
+ for (const seg of segments) groups[laneOf(seg)].segments.push(seg)
58
+ return groups
59
+ }
60
+
61
+ /** Every segment whose (coerced) lane is `lane`. */
62
+ export function segmentsInLane(segments: CaptionSegment[], lane: number): CaptionSegment[] {
63
+ return segments.filter(seg => laneOf(seg) === lane)
64
+ }
65
+
66
+ // ── Normalizing ──────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Collapse hole lanes and renumber dense from 0, preserving relative lane
70
+ * order (the segment(s) in the lowest occupied lane become lane 0, the next
71
+ * occupied lane becomes 1, and so on). Also rewrites any negative or
72
+ * non-integer stored `lane` to its canonical dense integer.
73
+ *
74
+ * Returns the SAME `Captions` reference when nothing changes — the contract
75
+ * every defensive normalizer in this codebase honours (see
76
+ * `backfillCaptionIds` in video/VideoEditor.tsx and `repairCaptionWords` in
77
+ * video/captionRepair.ts), so a caller that re-runs this after its own write
78
+ * converges to a true no-op instead of looping. Handles `captions` being
79
+ * `undefined` (passed straight back).
80
+ */
81
+ export function normalizeCaptionLanes(captions: Captions | undefined): Captions | undefined {
82
+ if (!captions) return captions
83
+ const { segments } = captions
84
+ if (!segments.length) return captions
85
+
86
+ // Distinct lanes actually in use (after defensive coercion), ascending —
87
+ // the lane at sorted position i renumbers to i, closing any holes. Lane 0,
88
+ // if present at all, is always the smallest and so always renumbers to 0.
89
+ const present = [...new Set(segments.map(laneOf))].sort((a, b) => a - b)
90
+ const remap = new Map(present.map((lane, i) => [lane, i]))
91
+
92
+ let changed = false
93
+ const newSegments = segments.map(seg => {
94
+ const canonical = remap.get(laneOf(seg))!
95
+ const current = seg.lane ?? 0
96
+ if (current === canonical) return seg
97
+ changed = true
98
+ return { ...seg, lane: canonical }
99
+ })
100
+ if (!changed) return captions
101
+
102
+ return { ...captions, segments: newSegments }
103
+ }
104
+
105
+ // ── Drag support ─────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Does `[start, end)` avoid every OTHER segment already occupying `lane`?
109
+ * `id` names the segment being placed — if it happens to already sit in
110
+ * `lane`, it is excluded from the check, which is what makes it safe to call
111
+ * this on a segment's own prospective new position while dragging it.
112
+ * Touching edges (`end === seg.start` or `start === seg.end`) are not a
113
+ * collision.
114
+ */
115
+ export function fitsInLane(
116
+ segments: CaptionSegment[],
117
+ id: string | undefined,
118
+ lane: number,
119
+ start: number,
120
+ end: number,
121
+ ): boolean {
122
+ return segmentsInLane(segments, lane).every(other => {
123
+ if (id !== undefined && other.id === id) return true // the segment itself — not a collision
124
+ return end <= other.start || start >= other.end
125
+ })
126
+ }
127
+
128
+ export interface CaptionLaneNeighbours {
129
+ prev: CaptionSegment | null
130
+ next: CaptionSegment | null
131
+ }
132
+
133
+ /**
134
+ * The nearest segments sharing `seg`'s lane on either side, by timeline
135
+ * position: `prev` is the closest segment ending at/before `seg.start`,
136
+ * `next` the closest starting at/after `seg.end`. `seg` itself is excluded
137
+ * (by id when it has one, otherwise by reference).
138
+ */
139
+ export function sameLaneNeighbours(segments: CaptionSegment[], seg: CaptionSegment): CaptionLaneNeighbours {
140
+ const others = segmentsInLane(segments, laneOf(seg)).filter(other =>
141
+ seg.id !== undefined ? other.id !== seg.id : other !== seg,
142
+ )
143
+
144
+ let prev: CaptionSegment | null = null
145
+ let next: CaptionSegment | null = null
146
+ for (const other of others) {
147
+ if (other.end <= seg.start && (!prev || other.end > prev.end)) prev = other
148
+ if (other.start >= seg.end && (!next || other.start < next.start)) next = other
149
+ }
150
+ return { prev, next }
151
+ }
152
+
153
+ export interface ResolveDropLaneArgs {
154
+ /** Full segment list (including `seg` at its pre-drag position). */
155
+ segments: CaptionSegment[]
156
+ /** The segment being dragged. */
157
+ seg: CaptionSegment
158
+ /** The lane the drag started from. */
159
+ sourceLane: number
160
+ /** Lanes of vertical travel so far (see sign note below). */
161
+ laneDelta: number
162
+ /** The segment's prospective new [start, end) in the target lane. */
163
+ start: number
164
+ end: number
165
+ }
166
+
167
+ /**
168
+ * The target lane for a vertical caption drag.
169
+ *
170
+ * Caption lanes ascend UPWARD on the timeline (higher lane number = higher on
171
+ * screen) — the OPPOSITE of audio lanes — so a downward drag (`laneDelta`
172
+ * increasing) must LOWER the lane number, hence the minus sign below. A later
173
+ * phase's drag gesture relies on this sign.
174
+ *
175
+ * Fans out from `wanted = clamp(sourceLane - laneDelta, 0, maxLane + 1)` in
176
+ * the order `wanted, wanted-1, wanted+1, wanted-2, …`, bounded to
177
+ * `[0, maxLane + 1]`, returning the first lane where `fitsInLane` holds. This
178
+ * mirrors the nearest-first fan-out `moveItemAcrossTracks` uses for visual
179
+ * tracks: land as close to the lane the pointer actually points at as
180
+ * possible, not just at the first open lane found in one direction.
181
+ *
182
+ * `maxLane + 1` is always in range and is empty of every segment (dragged one
183
+ * included — its OWN lane is at most `maxLane`, strictly less than
184
+ * `maxLane + 1`), so `fitsInLane` at that lane always holds. Because the
185
+ * fan-out's largest possible offset (`max(wanted, maxLane + 1 - wanted)`) never
186
+ * exceeds `maxLane + 1`, the search reaches that guaranteed-empty lane before
187
+ * running out of offsets to try — so this function always terminates with an
188
+ * answer, never falling through un-returned.
189
+ */
190
+ export function resolveDropLane({ segments, seg, sourceLane, laneDelta, start, end }: ResolveDropLaneArgs): number {
191
+ const upper = maxCaptionLane(segments) + 1
192
+ const wanted = Math.min(upper, Math.max(0, sourceLane - laneDelta))
193
+
194
+ for (let offset = 0; offset <= upper; offset++) {
195
+ const candidates = offset === 0 ? [wanted] : [wanted - offset, wanted + offset]
196
+ for (const candidate of candidates) {
197
+ if (candidate < 0 || candidate > upper) continue
198
+ if (fitsInLane(segments, seg.id, candidate, start, end)) return candidate
199
+ }
200
+ }
201
+ return upper // unreachable — see doc comment above — kept only so the function is total
202
+ }
@@ -11,6 +11,7 @@
11
11
  * downstream save/notify).
12
12
  */
13
13
  import type { Captions, CaptionSegment } from '../schema'
14
+ import { floorWordDurations } from './captionWordFloor'
14
15
 
15
16
  // Collapses any run of whitespace to a single space, in addition to trim +
16
17
  // case-fold, before the two sides are compared. `newWords` below is always
@@ -31,13 +32,16 @@ function repairSegment(seg: CaptionSegment): CaptionSegment {
31
32
  const newWords = seg.text.split(/\s+/).filter(Boolean)
32
33
  const segDur = seg.end - seg.start
33
34
  const wordDur = segDur / (newWords.length || 1)
35
+ const spreadWords = newWords.map((w, i) => ({
36
+ word: w,
37
+ start: seg.start + i * wordDur,
38
+ end: seg.start + (i + 1) * wordDur,
39
+ }))
34
40
  return {
35
41
  ...seg,
36
- words: newWords.map((w, i) => ({
37
- word: w,
38
- start: seg.start + i * wordDur,
39
- end: seg.start + (i + 1) * wordDur,
40
- })),
42
+ // Uniform spread has no minimum of its own — floor it so a short word
43
+ // never falls below a frame at any fps. See captionWordFloor.ts.
44
+ words: floorWordDurations(spreadWords, seg.end),
41
45
  }
42
46
  }
43
47
 
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Per-style default values for the caption text-styling fields — the ones
3
+ * each of the seven JSX caption templates applies via its own destructuring
4
+ * default (`fontWeight = 700`, etc.) whenever the corresponding `Captions`
5
+ * field is absent from the project.
6
+ *
7
+ * DELIBERATE DUPLICATION, the same shape as the `seg.lane ?? 0` duplication
8
+ * documented at the top of every template's `activeSegments` (see e.g.
9
+ * clean.jsx): the templates live in `montaj_assets/render/templates/
10
+ * captions/*.jsx`, compiled standalone for the browser/Puppeteer render
11
+ * path, and can import nothing but `montaj/render` — not this editor
12
+ * package, and not each other. The editor still needs these same numbers to
13
+ * show what an ABSENT field will actually render as — CaptionSpecimen's
14
+ * weight/family preview, and CaptionListPanel's steppers/case chips
15
+ * reporting the effective value instead of a blank one — and there is no
16
+ * module either side can import, so the values are copied here by hand.
17
+ *
18
+ * Whoever changes a template's own default MUST change the matching entry
19
+ * here too, in the same commit. Verified against the actual `function Xxx({
20
+ * ... })` destructuring defaults in:
21
+ * clean.jsx, karaoke.jsx, subtitle.jsx, pop.jsx, word-by-word.jsx,
22
+ * highlight-box.jsx, outline.jsx
23
+ * (all under montaj_assets/render/templates/captions/).
24
+ */
25
+ import type { Captions } from '../schema'
26
+
27
+ type Style = Captions['style']
28
+
29
+ /** `Captions.fontWeight`'s default per style (CSS `font-weight`). */
30
+ export const CAPTION_STYLE_FONT_WEIGHT: Record<Style, number> = {
31
+ clean: 700,
32
+ karaoke: 700,
33
+ subtitle: 600,
34
+ pop: 800,
35
+ 'word-by-word': 800,
36
+ 'highlight-box': 900,
37
+ outline: 900,
38
+ }
39
+
40
+ /** `Captions.fontFamily`'s default per style. Six of the seven templates
41
+ * share the plain system-sans stack as their parameter default; only
42
+ * `clean` names a specific face (Figtree) as its designed look. */
43
+ export const CAPTION_STYLE_FONT_FAMILY: Record<Style, string> = {
44
+ clean: '"Figtree", system-ui, sans-serif',
45
+ karaoke: 'system-ui, -apple-system, sans-serif',
46
+ subtitle: 'system-ui, -apple-system, sans-serif',
47
+ pop: 'system-ui, -apple-system, sans-serif',
48
+ 'word-by-word': 'system-ui, -apple-system, sans-serif',
49
+ 'highlight-box': 'system-ui, -apple-system, sans-serif',
50
+ outline: 'system-ui, -apple-system, sans-serif',
51
+ }
52
+
53
+ /** `Captions.letterSpacing`'s default per style. A style absent from this
54
+ * table has no default at all — `karaoke`, `subtitle`, `highlight-box`, and
55
+ * `outline` all destructure `letterSpacing` with no `= ...`, so an unset
56
+ * field renders with the browser's normal spacing on those four. */
57
+ export const CAPTION_STYLE_LETTER_SPACING: Partial<Record<Style, string>> = {
58
+ clean: '0.01em',
59
+ pop: '-0.02em',
60
+ 'word-by-word': '-0.02em',
61
+ }
62
+
63
+ /** `Captions.lineHeight`'s default per style.
64
+ *
65
+ * `highlight-box` and `outline` are a special case: their template has NO
66
+ * parameter-level default (a bare `lineHeight` destructure) — the value
67
+ * below only takes effect through an inline `lineHeight ?? 1.25` /
68
+ * `lineHeight ?? 1.15` at the WORDS-branch call site in `renderSegment`. A
69
+ * segment with no `words` array (the no-timestamps fallback branch) renders
70
+ * with no line-height at all, whether or not this field is set. `clean` and
71
+ * `subtitle` are plain parameter defaults and apply unconditionally.
72
+ * `karaoke`, `pop`, and `word-by-word` have no default in any branch. */
73
+ export const CAPTION_STYLE_LINE_HEIGHT: Partial<Record<Style, number>> = {
74
+ clean: 1.3,
75
+ subtitle: 1.4,
76
+ 'highlight-box': 1.25,
77
+ outline: 1.15,
78
+ }
79
+
80
+ /** `Captions.textAlign`'s default per style — every template centers text by
81
+ * default, with no exceptions. */
82
+ export const CAPTION_STYLE_TEXT_ALIGN: Record<Style, string> = {
83
+ clean: 'center',
84
+ karaoke: 'center',
85
+ subtitle: 'center',
86
+ pop: 'center',
87
+ 'word-by-word': 'center',
88
+ 'highlight-box': 'center',
89
+ outline: 'center',
90
+ }
91
+
92
+ /** `Captions.textTransform`'s default per style. Only `outline` has one — its
93
+ * all-caps stencil look — and, like its `lineHeight` default above, it only
94
+ * takes effect on the WORDS branch (`textTransform ?? 'uppercase'` at the
95
+ * `renderSegment` call site); the no-timestamps fallback branch renders the
96
+ * segment text as-is, never uppercased. Every other style has no default in
97
+ * any branch. */
98
+ export const CAPTION_STYLE_TEXT_TRANSFORM: Partial<Record<Style, string>> = {
99
+ outline: 'uppercase',
100
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * captionWordFloor — TS mirror of steps/lyrics/caption.py's
3
+ * `floor_word_durations`. Same waterfall, same constant (50ms — enough for
4
+ * >=1 frame at every fps montaj ships), same binary donation gate: a shared
5
+ * boundary between two words moves fully to `start_i + floor` or not at
6
+ * all, never partially, because the per-word caption templates select with
7
+ * `.find()` (first match wins) — a partial donation would leave a sliver of
8
+ * dead time where nothing matches, reproducing the invisibility bug the
9
+ * floor exists to fix. See caption.py's docstring for the full rationale;
10
+ * this file only documents the one deliberate divergence from it.
11
+ *
12
+ * WHY A MIRROR EXISTS AT ALL: caption.py is the single funnel for the SSE
13
+ * route and the CLI step, but two editor-side generators independently
14
+ * invent word timings with no minimum — `captionRepair.ts` and
15
+ * `makeCaptionEdit.ts`, both via a uniform `segDur / n` spread. This helper
16
+ * is applied to that spread's output, but on a uniform spread the donation
17
+ * gate is unsatisfiable by construction: it needs `2*dur - floor >= floor`,
18
+ * i.e. `dur >= floor`, which contradicts `dur < floor` — the precondition
19
+ * for a word being short enough to enter this function's donation logic at
20
+ * all. So the call is inert at both sites today; this file exists so a
21
+ * future non-uniform editor spread is covered without anyone having to
22
+ * remember it. (Hand-authored `PUT` captions stay uncovered by design —
23
+ * Montaj stores whatever it receives.)
24
+ *
25
+ * AND THAT INERTNESS IS DELIBERATE — do not "fix" it by widening the
26
+ * segment. The only way to give n words a floor inside a fixed segment is
27
+ * to make the segment longer (a uniform spread has no surplus to
28
+ * redistribute: `dur < floor` implies `segDur < n * floor`), and montaj
29
+ * follows CapCut here — editing a caption's text never moves its end.
30
+ * Duration is the user's to set; a caption holding more words than it has
31
+ * room for shows them fast, and the remedy is to lengthen or split the
32
+ * caption, both of which the timeline supports via caption trim handles.
33
+ * Auto-growing a caption because its text changed would surprise anyone
34
+ * coming from CapCut. See MASTER.md open thread 1.
35
+ *
36
+ * THE ONE DIVERGENCE: caption.py runs at the flatten, before any segment
37
+ * exists, so its transcript-final word has no `seg_end` to clamp against and
38
+ * is extended unconditionally into trailing silence. This mirror runs
39
+ * per-segment instead — both call sites regenerate `words` for ONE segment
40
+ * at a time from a freshly uniform-spread duration — so a segment end IS
41
+ * available here, and the segment-final word is extended only up to
42
+ * `segEnd`, never past it: overrunning the segment boundary would bleed
43
+ * this caption's last word into whatever comes next on the timeline.
44
+ */
45
+ import type { Word } from '../schema'
46
+
47
+ export const MIN_WORD_DURATION_S = 0.05
48
+
49
+ export function floorWordDurations(words: Word[], segEnd: number, floorS = MIN_WORD_DURATION_S): Word[] {
50
+ if (words.length === 0) return words
51
+
52
+ const out = words.map((w) => ({ ...w }))
53
+ const n = out.length
54
+ for (let i = 0; i < n; i++) {
55
+ const dur = out[i].end - out[i].start
56
+ if (dur >= floorS) continue
57
+
58
+ if (i + 1 >= n) {
59
+ // Segment-final word: extend up to the floor, clamped at segEnd — the
60
+ // one divergence from caption.py, see file header.
61
+ out[i].end = Math.min(out[i].end + (floorS - dur), segEnd)
62
+ continue
63
+ }
64
+
65
+ const gap = out[i + 1].start - out[i].end
66
+ if (gap > 0) {
67
+ // Non-contiguous: spend existing slack, never touch the successor.
68
+ out[i].end = Math.min(out[i].end + (floorS - dur), out[i + 1].start)
69
+ continue
70
+ }
71
+
72
+ // Contiguous: binary donation gate on the shared boundary.
73
+ const candidateBoundary = out[i].start + floorS
74
+ const successorNewDur = out[i + 1].end - candidateBoundary
75
+ if (successorNewDur >= floorS) {
76
+ out[i].end = candidateBoundary
77
+ out[i + 1].start = candidateBoundary
78
+ }
79
+ // else: donation refused. Word i is left completely unchanged.
80
+ }
81
+
82
+ return out
83
+ }