@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
@@ -19,19 +19,29 @@
19
19
  * reliably. Instead the rendered caption's bounding box is *measured* and a
20
20
  * separate interactive selection box is drawn over it (z 50, the same layer the
21
21
  * overlay handles use). That box is the only interactive element: clicking it
22
- * selects the segment active at the playhead, dragging it moves the segment
23
- * (offsetX/offsetY), and dragging a corner scales it (scale).
22
+ * selects the target segment, dragging it moves the segment (offsetX/offsetY),
23
+ * and dragging a corner scales it (scale).
24
+ *
25
+ * Captions have lanes (rows), so several segments can be on screen at once and
26
+ * the templates paint all of them. There is still exactly ONE selection box:
27
+ * it addresses the segment the operator already selected when that segment is
28
+ * on screen, otherwise the highest-lane one (the caption painted last, hence
29
+ * on top). It is measured from that segment's own `data-caption-id` subtree,
30
+ * so it wraps one caption rather than the union of everything on screen.
24
31
  */
25
32
 
26
33
  import { useEffect, useLayoutEffect, useRef, useState } from 'react'
34
+ import { activeCaptionSegments } from '@bycrux/timeline-core'
27
35
  import type { Captions } from '../../schema'
28
36
  import type { OverlayFactory } from '../../types'
29
37
  import OverlayErrorBoundary from '../../carousel/OverlayErrorBoundary'
38
+ import { ensureGoogleFontsLoaded } from '../../lib/google-fonts'
30
39
  import type { CaptionEditPatch } from '../timeline/makeCaptionEdit'
31
40
  import {
32
41
  captionDragGeometry,
33
42
  captionDragPatch,
34
43
  hasEscapedClickSlop,
44
+ measureCaptionContentRect,
35
45
  readCaptionGeometry,
36
46
  type CaptionDragState,
37
47
  type CaptionGeometry,
@@ -69,53 +79,6 @@ export function clampVisible(pos: number, size: number, extent: number): number
69
79
  return Math.max(min, Math.min(max, pos))
70
80
  }
71
81
 
72
- interface Rect { left: number; top: number; width: number; height: number }
73
-
74
- /**
75
- * Union of the client rects of every painted text run (and replaced element)
76
- * under `root`.
77
- *
78
- * Deliberately NOT `root.getBoundingClientRect()`: the template's own outermost
79
- * element is a frame-sized `position: fixed; inset: 0` box (captionOuterStyle),
80
- * so its rect — and the rect of any wrapper we put around it — is the entire
81
- * preview, which would put the selection box around the whole frame. Walking to
82
- * the text nodes is the only markup-agnostic way to find where the caption
83
- * actually paints, and it costs nothing on a subtree this small.
84
- *
85
- * Returns null when nothing is painted (e.g. `pop` renders no word between two
86
- * word windows), which is the signal to hide the selection box entirely.
87
- */
88
- export function measureCaptionContentRect(root: HTMLElement): Rect | null {
89
- const doc = root.ownerDocument
90
- const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT)
91
- const range = doc.createRange()
92
- let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity
93
-
94
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
95
- let rect: DOMRect | null = null
96
- if (node.nodeType === Node.TEXT_NODE) {
97
- if (!node.nodeValue?.trim()) continue
98
- range.selectNodeContents(node)
99
- rect = range.getBoundingClientRect()
100
- } else {
101
- // Replaced elements paint without text nodes. Everything else is a
102
- // container whose box is either the frame-sized outer wrapper or a
103
- // full-width anchor box — including them would defeat the point.
104
- const tag = (node as Element).tagName?.toUpperCase()
105
- if (tag !== 'IMG' && tag !== 'SVG' && tag !== 'VIDEO' && tag !== 'CANVAS') continue
106
- rect = (node as Element).getBoundingClientRect()
107
- }
108
- if (!rect || (!rect.width && !rect.height)) continue
109
- left = Math.min(left, rect.left)
110
- top = Math.min(top, rect.top)
111
- right = Math.max(right, rect.right)
112
- bottom = Math.max(bottom, rect.bottom)
113
- }
114
-
115
- if (!Number.isFinite(left)) return null
116
- return { left, top, width: right - left, height: bottom - top }
117
- }
118
-
119
82
  interface CaptionPreviewProps {
120
83
  track: Captions
121
84
  currentTime: number
@@ -124,7 +87,7 @@ interface CaptionPreviewProps {
124
87
  resolveCaptionTemplate?: (style: string) => string
125
88
  /** Currently-selected caption segment id (shared with the timeline row). */
126
89
  selectedCaptionId?: string
127
- /** Click on the selection box — selects the segment active at the playhead.
90
+ /** Click on the selection box — selects the segment the box is addressing.
128
91
  * Accepts null for symmetry with the timeline row's copy of this callback
129
92
  * (VideoEditor supplies one handler to both); the preview only ever passes
130
93
  * an id, since it has no deselect affordance of its own. */
@@ -187,33 +150,54 @@ export default function CaptionPreview({
187
150
  const frame = Math.round(currentTime * fps)
188
151
  const lastSeg = track.segments[track.segments.length - 1]
189
152
 
190
- // The segment the template is showing. Computed from `frame / fps` (not from
191
- // currentTime) with the templates' own `>= start && < end` predicate, so the
192
- // selection box can never address a different segment than the one on screen.
193
- const t = fps > 0 ? frame / fps : 0
194
- const activeSeg = track.segments.find(s => t >= s.start && t < s.end) ?? null
153
+ // EVERY segment the template is showing, lane-ascending. `activeCaptionSegments`
154
+ // quantizes to `frame / fps` (not raw currentTime) and applies the templates'
155
+ // own `>= start && < end` predicate, so the selection box can never address a
156
+ // segment that is not on screen. Captions have lanes, so more than one can be
157
+ // painted at once — the templates draw them all.
158
+ const activeSegs = activeCaptionSegments(track, currentTime, fps)
159
+
160
+ // The ONE segment the selection box addresses. With several captions on
161
+ // screen "the active one" is ambiguous, so: the operator's current selection
162
+ // if it is among them, otherwise the highest-lane one — the caption painted
163
+ // last, and therefore the one visually on top at that instant.
164
+ const targetSeg =
165
+ (selectedCaptionId ? activeSegs.find(s => s.id === selectedCaptionId) : undefined)
166
+ ?? activeSegs[activeSegs.length - 1]
167
+ ?? null
195
168
 
196
169
  // Interactivity requires a stable segment id (backfilled by VideoEditor) and a
197
170
  // host that wired at least one caption callback. Without both, the layer stays
198
171
  // exactly as it was: a passive, click-through caption overlay.
199
- const activeId = activeSeg?.id
200
- const canInteract = !!activeId && !!(onSelectCaption || onCaptionSegmentChange)
201
- const isSelected = canInteract && selectedCaptionId === activeId
172
+ const targetId = targetSeg?.id
173
+ const canInteract = !!targetId && !!(onSelectCaption || onCaptionSegmentChange)
174
+ const isSelected = canInteract && selectedCaptionId === targetId
202
175
 
203
176
  // The box unmounts when the playhead leaves a segment, so a pending
204
177
  // mouseleave never arrives and the hover hairline would carry over onto the
205
178
  // next segment's box. Drop it whenever the target segment changes; a pointer
206
179
  // that really is over the new box gets its mouseenter back on the next move.
207
- useEffect(() => { setHovered(false) }, [activeId])
180
+ useEffect(() => { setHovered(false) }, [targetId])
208
181
 
209
182
  // Theme props for the template: everything on the track except style/segments
210
- // (handled separately) and googleFonts (a render-time font-loading hint, not
211
- // a template prop). Normalize the legacy lowercase `fontsize` key to the
212
- // camelCase `fontSize` templates actually read.
213
- const { style: _style, segments: _segments, googleFonts: _googleFonts, fontsize, ...theme } = track
183
+ // (handled separately) and googleFonts — not a template prop, but the editor
184
+ // must still LOAD it (see the effect below) so preview and render use the same
185
+ // face. Normalize the legacy lowercase `fontsize` key to the camelCase
186
+ // `fontSize` templates actually read.
187
+ const { style: _style, segments: _segments, googleFonts, fontsize, ...theme } = track
214
188
  const themeProps: Record<string, unknown> = { ...theme }
215
189
  if (fontsize != null) themeProps.fontSize = fontsize
216
190
 
191
+ // Inject the caption track's Google Fonts so the preview paints the same face
192
+ // the renderer fetches (render.js hands `captions.googleFonts` to
193
+ // bundleComponent, which injects the same stylesheet into the Puppeteer page).
194
+ // Without this, `captions.fontFamily` reaches the template in both paths but
195
+ // only the render has the font file — a silent preview/render divergence.
196
+ // Depend on the joined list rather than the array: `track` is a fresh object
197
+ // on every project edit, and String() flattens both the typed string[] and the
198
+ // bare string persisted projects occasionally carry (see google-fonts.ts).
199
+ useEffect(() => { ensureGoogleFontsLoaded(googleFonts) }, [String(googleFonts)])
200
+
217
201
  // Live drag preview: overlay the in-flight geometry onto the dragged segment
218
202
  // only. The templates read offsetX/offsetY/scale straight off the segment, so
219
203
  // this is all it takes for the caption to follow the cursor.
@@ -265,7 +249,7 @@ export default function CaptionPreview({
265
249
  }, [drag, scale])
266
250
 
267
251
  function startGesture(type: CaptionDragState['type'], e: React.MouseEvent) {
268
- if (!activeId) return
252
+ if (!targetId) return
269
253
  // Keep the mousedown from starting a text selection and from reaching the
270
254
  // player's play/pause layer underneath.
271
255
  e.preventDefault()
@@ -273,11 +257,11 @@ export default function CaptionPreview({
273
257
  // Select and begin the gesture in the same press: unlike overlays (selected
274
258
  // from the timeline) the preview box IS the caption's selection affordance,
275
259
  // so requiring a separate click first would just cost a click.
276
- if (selectedCaptionId !== activeId) onSelectCaption?.(activeId)
260
+ if (selectedCaptionId !== targetId) onSelectCaption?.(targetId)
277
261
  if (!onCaptionSegmentChange) return
278
- const g = readCaptionGeometry(activeSeg)
262
+ const g = readCaptionGeometry(targetSeg)
279
263
  setDrag({
280
- id: activeId, type,
264
+ id: targetId, type,
281
265
  initX: e.clientX, initY: e.clientY,
282
266
  initOffsetX: g.offsetX, initOffsetY: g.offsetY, initScale: g.scale,
283
267
  })
@@ -295,7 +279,11 @@ export default function CaptionPreview({
295
279
  const root = wrapRef.current
296
280
  const content = contentRef.current
297
281
  if (!box || !root || !content) return
298
- const rect = measureCaptionContentRect(content)
282
+ // Measure the TARGET segment's own subtree, not the whole layer: with two
283
+ // captions on screen the union of both would wrap a box around the pair.
284
+ // A template that emits no `data-caption-id` marker falls back to the whole
285
+ // root inside measureCaptionContentRect, i.e. the pre-lane behaviour.
286
+ const rect = measureCaptionContentRect(content, targetId)
299
287
  if (!rect || rect.width <= 0 || rect.height <= 0) {
300
288
  box.style.display = 'none'
301
289
  return
@@ -307,7 +295,7 @@ export default function CaptionPreview({
307
295
  const h = rect.height + BOX_PAD * 2
308
296
  // Keep a grabbable sliver of the box inside the frame. offsetX/offsetY are
309
297
  // unbounded and there is no numeric reset control for captions (unlike
310
- // overlays, which have OverlayPropsModal), so a segment dragged fully past
298
+ // overlays, whose Transform panel has one), so a segment dragged fully past
311
299
  // the frame edge would have BOTH its text and its selection box clipped
312
300
  // away by this wrapper's `overflow-hidden` — leaving no way to drag it back
313
301
  // short of hand-editing project.json. Dragging is delta-based (see
@@ -0,0 +1,103 @@
1
+ /**
2
+ * SP4 T6/T7 — the engine's picture surface: one canvas, the Preparing
3
+ * placeholder for the state where there is nothing to paint yet, and the
4
+ * debug HUD.
5
+ *
6
+ * It renders INSIDE `PreviewPlayer`'s existing transform container, in the
7
+ * position the two `<video>` slots occupy on the legacy path, with the same
8
+ * `z-index: 1` the active slot carries. The container keeps owning
9
+ * `scale`/`offsetX`/`offsetY` in CSS (plan decision 3: transform container,
10
+ * sourceCrop semantics, z-order and handles stay in CSS unchanged), so nothing
11
+ * about drag, resize or the selection outline changes between the two paths.
12
+ *
13
+ * The canvas is deliberately style-only-sized: `engine.attach()` sets the
14
+ * BACKING STORE to the project's design canvas and the painter re-reads
15
+ * `canvas.width`/`height` on every paint, so the element can be stretched to
16
+ * the frame box without touching the engine. `sourceCrop` is NOT applied here —
17
+ * the engine reproduces `sourceCropStyle.ts`'s algebra as a `drawImage` source
18
+ * rect (`scheduler.ts`'s `sourceCropDrawPlan`), so a second CSS crop on top
19
+ * would double-apply it.
20
+ *
21
+ * ── The Preparing placeholder (T7) ──
22
+ * Reachable ONLY for a clip that loses its proxy AFTER engine mode already
23
+ * started for this session — a mid-session addition, or a proxy that is still
24
+ * encoding, or one that failed to load/decode (`scheduler.ts`'s `picture ===
25
+ * 'preparing'` branch routes all three through the same state). A project
26
+ * that was ineligible at LOAD stays on the legacy player for the whole session
27
+ * (plan decision 2, enforced in `PreviewPlayer.tsx`'s `useEngineMode`) and
28
+ * never mounts this component at all — so "ineligible at load" can never reach
29
+ * this placeholder either.
30
+ *
31
+ * Shown only after `PREPARING_SHOW_DELAY_MS` of SUSTAINED `preparing` — an
32
+ * ordinary boundary swap can pass through `preparing` for a single tick while
33
+ * the next clip's session finishes attaching (prewarm usually lands it before
34
+ * the cut, but not always), and a placeholder that blinked on and off for one
35
+ * frame at every such swap would read as a glitch rather than as information.
36
+ * A proxy that is genuinely still encoding, or genuinely failed, is preparing
37
+ * for whole seconds — comfortably past the delay either way.
38
+ */
39
+ import { useEffect, useState } from 'react'
40
+ import type { EngineStats, Picture } from '../../engine'
41
+ import DebugHud from '../../engine/debug-hud'
42
+ import { Loader } from '../../ui/Loader'
43
+
44
+ /**
45
+ * How long `picture` must stay `preparing` before the placeholder shows.
46
+ * Short enough that a genuinely-stuck clip still reads as "preparing" almost
47
+ * immediately; long enough that a one-tick transitional `preparing` during a
48
+ * boundary swap never paints at all (React's effect cleanup cancels the timer
49
+ * the instant `picture` moves on — see the effect below).
50
+ */
51
+ const PREPARING_SHOW_DELAY_MS = 200
52
+
53
+ interface EngineSurfaceProps {
54
+ /**
55
+ * The hook's `attachCanvas`. Passed straight to `ref`, so it MUST be stable —
56
+ * a fresh identity each render makes React detach (`null`) and re-attach on
57
+ * every tick, which would unbind the engine's painter sixty times a second.
58
+ */
59
+ attach: (canvas: HTMLCanvasElement | null) => void
60
+ picture: Picture
61
+ /** Why the picture is `preparing`, when it is. Shown as a hover title only — some reasons (decode error text, the src-precedence message) are not end-user copy. */
62
+ reason?: string
63
+ /** `engine.debugHud`, threaded from `PreviewPlayer`'s `engine` prop. Absent/false: no HUD, no polling. */
64
+ debugHud?: boolean
65
+ /** The hook's `getStats`. Read by the HUD on its own timer; unused when `debugHud` is falsy. */
66
+ getStats: () => EngineStats | null
67
+ }
68
+
69
+ export default function EngineSurface({ attach, picture, reason, debugHud, getStats }: EngineSurfaceProps) {
70
+ const [showPreparing, setShowPreparing] = useState(false)
71
+
72
+ useEffect(() => {
73
+ if (picture !== 'preparing') {
74
+ setShowPreparing(false)
75
+ return
76
+ }
77
+ const timer = setTimeout(() => setShowPreparing(true), PREPARING_SHOW_DELAY_MS)
78
+ // Cleanup fires the instant `picture` changes away from `preparing` (or on
79
+ // unmount) — a swap that resolves inside the delay window never shows.
80
+ return () => clearTimeout(timer)
81
+ }, [picture])
82
+
83
+ return (
84
+ <>
85
+ <canvas
86
+ ref={attach}
87
+ data-montaj-engine-canvas=""
88
+ style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 1 }}
89
+ />
90
+ {showPreparing && (
91
+ <div
92
+ className="montaj-engine-preparing absolute inset-0 flex flex-col items-center justify-center gap-2 text-white/80"
93
+ style={{ zIndex: 2 }}
94
+ title={reason}
95
+ >
96
+ <Loader size="md" />
97
+ <span className="text-sm">Preparing preview…</span>
98
+ </div>
99
+ )}
100
+ {debugHud && <DebugHud getStats={getStats} />}
101
+ </>
102
+ )
103
+ }