@bycrux/editor 0.11.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -1,18 +1,29 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
2
+ import { containsTime, geometryAt, geometryFor } from '@bycrux/timeline-core'
3
+ import { isProxyUsable, markProxyFailed } from './proxySupport'
2
4
  import type { EditorProject as Project, VisualItem } from '../../schema'
3
5
  import type { OverlayFactory } from '../../types'
4
6
  import OverlayErrorBoundary from '../../carousel/OverlayErrorBoundary'
5
7
  import { getOverlayDesignCanvas } from '../design-canvas'
6
8
  import { ensureGoogleFontsLoaded } from '../../lib/google-fonts'
7
- import type { Corner, OverlayChanges } from './useDragOverlay'
9
+ import type { Corner, Edge, OverlayChanges } from './useDragOverlay'
8
10
  import type { useDragOverlay } from './useDragOverlay'
11
+ import { enabledTrackItems } from '../timeline/timeline-model'
9
12
 
10
- const VIDEO_PRELOAD_S = 0.4 // mount this many seconds before item.start so the frame is ready
13
+ // Mount video items this many seconds before item.start so the frame is ready.
14
+ //
15
+ // This pre-mount window is deliberately NOT part of @bycrux/timeline-core's
16
+ // activation predicate: a pre-mounted item is rendered at opacity 0 and is not
17
+ // "on screen" in any sense the renderer or sample-frame would agree with. It
18
+ // exists purely to give the browser time to decode the first frame so the item
19
+ // doesn't flash in — presentation, not activation. `containsTime` (the shared
20
+ // predicate) still decides `visible`; see timeline-core/index.d.ts.
21
+ const VIDEO_PRELOAD_S = 0.4
11
22
 
12
23
  // Synced video overlay — seeks to the correct position within the item's inPoint/outPoint range
13
- function OverlayVideo({ src, currentTime, itemStart, inPoint, isPlaying, muted, visible }: {
14
- src: string; currentTime: number; itemStart: number; inPoint: number
15
- isPlaying: boolean; muted?: boolean; visible: boolean
24
+ function OverlayVideo({ src, currentTime, itemStart, inPoint, speed = 1, isPlaying, muted, visible, onSrcError }: {
25
+ src: string; currentTime: number; itemStart: number; inPoint: number; speed?: number
26
+ isPlaying: boolean; muted?: boolean; visible: boolean; onSrcError?: () => void
16
27
  }) {
17
28
  const ref = useRef<HTMLVideoElement>(null)
18
29
  // Refs so the onSeeked handler can read current playback intent without stale closures
@@ -28,7 +39,11 @@ function OverlayVideo({ src, currentTime, itemStart, inPoint, isPlaying, muted,
28
39
  useEffect(() => {
29
40
  const v = ref.current
30
41
  if (!v) return
31
- const target = Math.max(inPoint, inPoint + (currentTime - itemStart))
42
+ // Source time = inPoint + S·(projectTime − start), matching timeline-core's
43
+ // `seekTime`. Strict no-op at S=1. A sped/slowed overlay walks its source
44
+ // faster/slower than project time, so without S it would seek to the wrong
45
+ // frame (and, past S=1, run off the end).
46
+ const target = Math.max(inPoint, inPoint + speed * (currentTime - itemStart))
32
47
  v.currentTime = target
33
48
  }, [])
34
49
 
@@ -40,24 +55,31 @@ function OverlayVideo({ src, currentTime, itemStart, inPoint, isPlaying, muted,
40
55
  const v = ref.current
41
56
  if (!v) return
42
57
  if (v.readyState < 2) return
43
- const target = inPoint + (currentTime - itemStart)
58
+ const target = inPoint + speed * (currentTime - itemStart)
44
59
  const drift = Math.abs(v.currentTime - target)
45
60
  if (!v.paused && drift < 1.5) return
46
61
  if (drift > 0.3) {
47
62
  v.currentTime = Math.max(inPoint, target)
48
63
  }
49
- }, [currentTime, itemStart, inPoint])
64
+ }, [currentTime, itemStart, inPoint, speed])
50
65
 
51
66
  // Play/pause sync — only play when visible; pause when pre-loading or past end
52
67
  useEffect(() => {
53
68
  const v = ref.current
54
69
  if (!v) return
70
+ // Play at S× so one project-second consumes S source-seconds — keeps the
71
+ // element in step with project time instead of drifting until a re-seek
72
+ // fires. `preservesPitch` matters only for an audible overlay; harmless
73
+ // otherwise. Set before play(); a fresh src load resets rate to 1, so the
74
+ // dependency list re-runs this whenever speed changes under us.
75
+ v.playbackRate = speed
76
+ v.preservesPitch = true
55
77
  if (isPlaying && visible) {
56
78
  v.play().catch(() => {})
57
79
  } else {
58
80
  v.pause()
59
81
  }
60
- }, [isPlaying, visible])
82
+ }, [isPlaying, visible, speed])
61
83
 
62
84
  return (
63
85
  <video
@@ -68,6 +90,7 @@ function OverlayVideo({ src, currentTime, itemStart, inPoint, isPlaying, muted,
68
90
  src={src}
69
91
  muted={muted}
70
92
  preload="auto"
93
+ onError={onSrcError}
71
94
  onSeeked={() => {
72
95
  // After a mid-clip seek the browser may have paused to buffer — restart if we should be playing
73
96
  const v = ref.current
@@ -172,8 +195,9 @@ function CustomOverlay({
172
195
  }, [src, watchFile, compile])
173
196
 
174
197
  // Deep-clone/rewrite the props once per props change instead of every frame.
175
- // Live prop edits (OverlayPropsModal → VideoEditor.withItemProps) always
176
- // produce a new `props` object reference, so this recomputes on every edit.
198
+ // Live prop edits (the panel's Content tab → VideoEditor.withItemProps)
199
+ // always produce a new `props` object reference, so this recomputes on every
200
+ // edit.
177
201
  const resolvedProps = useMemo(
178
202
  () => resolveOverlayPropPaths(props, fileUrl) as Record<string, unknown>,
179
203
  [props, fileUrl],
@@ -198,56 +222,97 @@ function CustomOverlay({
198
222
  }
199
223
 
200
224
  // ---------------------------------------------------------------------------
201
- // Corner handle — L-shaped bracket that stays a fixed visual size
225
+ // Selection chrome — box, resize handles, rotate handle, snap guides
202
226
  // ---------------------------------------------------------------------------
203
227
 
204
- function CornerHandle({ corner, scale, onMouseDown }: {
205
- corner: Corner
206
- scale: number
228
+ // Everything the selection draws reads the host theme's selection colour, so a
229
+ // selected OVERLAY and a selected base CLIP are the same object to the eye. The
230
+ // clip's box is PreviewPlayer.tsx (~:526-559): a 2px outline plus 12px white
231
+ // squares with a 1.5px selection-coloured border. These constants exist so the
232
+ // two can't drift into "nearly the same" — change the look in one place.
233
+ const SELECTION = 'var(--editor-selection)'
234
+ const SELECTION_OUTLINE = `2px solid ${SELECTION}`
235
+ const HANDLE_PX = 12
236
+
237
+ /** White square with a selection-coloured border — the clip box's handle. */
238
+ const HANDLE_FACE: React.CSSProperties = {
239
+ width: HANDLE_PX,
240
+ height: HANDLE_PX,
241
+ backgroundColor: '#fff',
242
+ border: `1.5px solid ${SELECTION}`,
243
+ borderRadius: 2,
244
+ }
245
+
246
+ // Eight handles: four corners scale BOTH axes together, four edge midpoints
247
+ // scale exactly one (`useDragOverlay` maps `resize-e`/`resize-w` to X and
248
+ // `resize-n`/`resize-s` to Y). Ordered corners-then-edges only for readability.
249
+ const RESIZE_HANDLES: Array<Corner | Edge> = ['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w']
250
+
251
+ const HANDLE_CURSOR: Record<Corner | Edge, string> = {
252
+ nw: 'cursor-nw-resize', ne: 'cursor-ne-resize',
253
+ sw: 'cursor-sw-resize', se: 'cursor-se-resize',
254
+ n: 'cursor-ns-resize', s: 'cursor-ns-resize',
255
+ e: 'cursor-ew-resize', w: 'cursor-ew-resize',
256
+ }
257
+
258
+ /** Anchor point on the item's bounding box, in % of the box. */
259
+ const HANDLE_ANCHOR: Record<Corner | Edge, [number, number]> = {
260
+ nw: [0, 0], n: [50, 0], ne: [100, 0],
261
+ w: [0, 50], e: [100, 50],
262
+ sw: [0, 100], s: [50, 100], se: [100, 100],
263
+ }
264
+
265
+ // Every chrome element inside the item's wrapper inherits the wrapper's
266
+ // `scale(scaleX, scaleY)`, so each counter-scales by the INVERSE OF BOTH AXES to
267
+ // stay a constant, SQUARE visual size. A single `scale(1/s)` would leave the
268
+ // handles visibly stretched into rectangles on a non-uniformly scaled item.
269
+ function ResizeHandle({ handle, scaleX, scaleY, onMouseDown }: {
270
+ handle: Corner | Edge
271
+ scaleX: number
272
+ scaleY: number
207
273
  onMouseDown: (e: React.MouseEvent) => void
208
274
  }) {
209
- const cursorClass = {
210
- nw: 'cursor-nw-resize', ne: 'cursor-ne-resize',
211
- sw: 'cursor-sw-resize', se: 'cursor-se-resize',
212
- }[corner]
213
-
214
- const posClass = {
215
- nw: 'top-0 left-0', ne: 'top-0 right-0',
216
- sw: 'bottom-0 left-0', se: 'bottom-0 right-0',
217
- }[corner]
218
-
219
- // L-shaped bracket: show only the two relevant border sides
220
- const borderClass = {
221
- nw: 'border-t-2 border-l-2',
222
- ne: 'border-t-2 border-r-2',
223
- sw: 'border-b-2 border-l-2',
224
- se: 'border-b-2 border-r-2',
225
- }[corner]
226
-
227
- // Inverse scale so handle stays constant visual size; origin at the corner itself
228
- const origin = `${corner.includes('n') ? 'top' : 'bottom'} ${corner.includes('w') ? 'left' : 'right'}`
229
-
275
+ const [ax, ay] = HANDLE_ANCHOR[handle]
230
276
  return (
231
277
  <div
232
- className={`absolute w-5 h-5 border-amber-400 z-50 ${cursorClass} ${posClass} ${borderClass}`}
233
- style={{ transformOrigin: origin, transform: `scale(${1 / scale})` }}
278
+ data-handle={handle}
279
+ className={`absolute z-50 ${HANDLE_CURSOR[handle]}`}
280
+ style={{
281
+ ...HANDLE_FACE,
282
+ // `left`/`top` put the handle's TOP-LEFT on the anchor point, and the
283
+ // origin is pinned there too — so the transform below is measured from
284
+ // the anchor, not from the handle's own centre.
285
+ left: `${ax}%`,
286
+ top: `${ay}%`,
287
+ transformOrigin: '0 0',
288
+ // Order is load-bearing: `scale` OUTSIDE `translate`. The -50%/-50%
289
+ // resolves against the handle's own 12px box, and only in this order
290
+ // does the wrapper's scale(sx, sy) cancel out of BOTH the size and the
291
+ // centring offset — leaving a constant 12px square centred on the
292
+ // anchor at any scale. `translate(...) scale(...)` would leave the
293
+ // centring offset scaled and walk the handle off the box.
294
+ transform: `scale(${1 / scaleX}, ${1 / scaleY}) translate(-50%, -50%)`,
295
+ }}
234
296
  onMouseDown={onMouseDown}
235
297
  />
236
298
  )
237
299
  }
238
300
 
239
- function RotateHandle({ scale, onMouseDown }: {
240
- scale: number
301
+ function RotateHandle({ scaleX, scaleY, onMouseDown }: {
302
+ scaleX: number
303
+ scaleY: number
241
304
  onMouseDown: (e: React.MouseEvent) => void
242
305
  }) {
243
306
  return (
244
307
  <div
245
308
  className="absolute top-0 left-1/2 z-50 cursor-grab flex flex-col items-center"
246
- style={{ transform: `translateX(-50%) translateY(-100%) scale(${1 / scale})`, transformOrigin: 'bottom center' }}
309
+ style={{ transform: `translateX(-50%) translateY(-100%) scale(${1 / scaleX}, ${1 / scaleY})`, transformOrigin: 'bottom center' }}
247
310
  onMouseDown={onMouseDown}
248
311
  >
249
- <div className="w-4 h-4 rounded-full border-2 border-amber-400 bg-black/60" />
250
- <div className="w-px h-3 bg-amber-400" />
312
+ {/* Same white square as a resize handle, lifted clear of the box on a
313
+ short stalk so it never collides with the `n` edge handle. */}
314
+ <div style={HANDLE_FACE} />
315
+ <div style={{ width: 1, height: HANDLE_PX, backgroundColor: SELECTION }} />
251
316
  </div>
252
317
  )
253
318
  }
@@ -256,15 +321,23 @@ function RotateHandle({ scale, onMouseDown }: {
256
321
  // image's bounding box; counter-scales so it stays a constant size regardless of
257
322
  // the item's scale. 'fill' is the legacy stretch behavior (kept for opt-in).
258
323
  const FIT_OPTIONS: Array<'cover' | 'contain' | 'fill'> = ['cover', 'contain', 'fill']
259
- function FitControl({ value, scale, onChange }: {
324
+ function FitControl({ value, scaleX, scaleY, onChange }: {
260
325
  value: 'cover' | 'contain' | 'fill'
261
- scale: number
326
+ scaleX: number
327
+ scaleY: number
262
328
  onChange: (fit: 'cover' | 'contain' | 'fill') => void
263
329
  }) {
264
330
  return (
265
331
  <div
266
- className="absolute bottom-0 left-1/2 z-50 flex gap-px rounded bg-black/70 border border-amber-400/50 overflow-hidden"
267
- style={{ transform: `translateX(-50%) translateY(140%) scale(${1 / scale})`, transformOrigin: 'top center' }}
332
+ // Part of the selection treatment (it only exists while the item is
333
+ // selected), so it rides the same token as the box and handles rather
334
+ // than keeping an accent of its own.
335
+ className="absolute bottom-0 left-1/2 z-50 flex gap-px rounded bg-black/70 border overflow-hidden"
336
+ style={{
337
+ borderColor: SELECTION,
338
+ transform: `translateX(-50%) translateY(140%) scale(${1 / scaleX}, ${1 / scaleY})`,
339
+ transformOrigin: 'top center',
340
+ }}
268
341
  onMouseDown={(e) => e.stopPropagation()}
269
342
  >
270
343
  {FIT_OPTIONS.map(opt => (
@@ -273,8 +346,9 @@ function FitControl({ value, scale, onChange }: {
273
346
  type="button"
274
347
  onClick={(e) => { e.stopPropagation(); onChange(opt) }}
275
348
  className={`px-2 py-1 text-[11px] font-mono capitalize ${
276
- value === opt ? 'bg-amber-400 text-black' : 'text-gray-300 hover:bg-white/10'
349
+ value === opt ? 'text-black' : 'text-gray-300 hover:bg-white/10'
277
350
  }`}
351
+ style={value === opt ? { backgroundColor: SELECTION } : undefined}
278
352
  >
279
353
  {opt}
280
354
  </button>
@@ -338,55 +412,83 @@ export default function OverlayItemsLayer({
338
412
  fileUrl,
339
413
  }: OverlayItemsLayerProps) {
340
414
  const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
415
+ // SP3 fix B2: re-render trigger for proxy decode failures — marking a proxy
416
+ // failed flips isProxyUsable() below, swapping the overlay video back to its
417
+ // master src on the forced re-render.
418
+ const [, bumpProxyFail] = useReducer((x: number) => x + 1, 0)
341
419
 
342
420
  // Interactive tracks — in canvas mode this includes track 0; otherwise overlays only.
343
- const interactiveTracks = isCanvasProject ? project.tracks ?? [] : overlayTracks
421
+ // Enabled only: a skipped track must not be draggable in the preview either.
422
+ const interactiveTracks = isCanvasProject ? enabledTrackItems(project) : overlayTracks
344
423
 
345
424
  return (
346
425
  <>
347
426
  {/* tracks[0] non-video items (background images) — rendered with drag support at base z-level */}
348
427
  {!isCanvasProject && tracks0NonVideo.map((item) => {
349
428
  if (item.type !== 'image' || !item.src) return null
350
- const visible = currentTime >= item.start && currentTime < item.end
429
+ const visible = containsTime(item.start, item.end, currentTime)
351
430
  if (!visible) return null
352
431
  const isSel = selectedOverlayId === item.id
353
- const offsetX = (liveOffset?.id === item.id ? liveOffset.x : null) ?? item.offsetX ?? 0
354
- const offsetY = (liveOffset?.id === item.id ? liveOffset.y : null) ?? item.offsetY ?? 0
355
- const scale = (liveScale?.id === item.id ? liveScale.scale : null) ?? item.scale ?? 1
356
- const rotation = (liveRotation?.id === item.id ? liveRotation.rotation : null) ?? item.rotation ?? 0
432
+ // Persisted geometry from the resolver; live drag state layered on top.
433
+ // The resolver only ever sees the SAVED project, so it cannot know about
434
+ // an in-flight drag — `liveOffset`/`liveScale`/`liveRotation` must keep
435
+ // winning, with `geometryFor` supplying the base each falls back to.
436
+ // Animated for the same reason as the items branch below: the renderer
437
+ // composites tracks[0] images through the very same
438
+ // buildImageItemFilterParts, which since SP9d compiles their curves into
439
+ // ffmpeg expressions. Leaving this branch on the static resolve would
440
+ // put back a preview/render divergence in the one place nobody would
441
+ // look for it. Opacity stays static here too — ffmpeg cannot vary it.
442
+ const gAnimated = geometryAt(item, 'image', currentTime - item.start)
443
+ const g = { ...gAnimated, opacity: geometryFor(item, 'image').opacity }
444
+ const fit = g.fit ?? 'cover'
445
+ const offsetX = (liveOffset?.id === item.id ? liveOffset.x : null) ?? g.offsetX
446
+ const offsetY = (liveOffset?.id === item.id ? liveOffset.y : null) ?? g.offsetY
447
+ const scale = (liveScale?.id === item.id ? liveScale.scale : null) ?? g.scale
448
+ // The rendered box is per-axis; `scale` above stays the uniform value the
449
+ // drag state is seeded with. `g.scaleX`/`g.scaleY` already fall back to
450
+ // `g.scale`, so a legacy scale-only item resolves both to the same number.
451
+ const scaleX = (liveScale?.id === item.id ? liveScale.scaleX : null) ?? g.scaleX
452
+ const scaleY = (liveScale?.id === item.id ? liveScale.scaleY : null) ?? g.scaleY
453
+ const rotation = (liveRotation?.id === item.id ? liveRotation.rotation : null) ?? g.rotation
454
+ const hasPerAxis = item.scaleX != null || item.scaleY != null
357
455
  const wrapperStyle: React.CSSProperties = {
358
- transform: `translate(${offsetX}%, ${offsetY}%) rotate(${rotation}deg) scale(${scale})`,
456
+ transform: `translate(${offsetX}%, ${offsetY}%) rotate(${rotation}deg) scale(${scaleX}, ${scaleY})`,
359
457
  transformOrigin: 'center center',
360
458
  // Raise above play/pause div (z=10) when selected so pointer events land here
361
459
  zIndex: isSel ? 11 : 2,
362
- opacity: item.opacity ?? 1,
460
+ opacity: g.opacity,
461
+ // Selection box — the same 2px token outline a selected base clip
462
+ // gets (PreviewPlayer.tsx ~:536), so the two read as one treatment.
463
+ ...(isSel ? { outline: SELECTION_OUTLINE } : null),
363
464
  }
364
465
  const wrapperClass = `absolute inset-0 ${
365
466
  isSel
366
- ? `${dragState?.type === 'move' ? 'cursor-grabbing' : 'cursor-grab'} ring-1 ring-inset ring-amber-400/40`
467
+ ? (dragState?.type === 'move' ? 'cursor-grabbing' : 'cursor-grab')
367
468
  : 'pointer-events-none'
368
469
  }`
470
+ const initGeom = { initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initScaleX: scaleX, initScaleY: scaleY, initHasPerAxis: hasPerAxis, initRotation: rotation }
369
471
  function startMove(e: React.MouseEvent) {
370
472
  if (!isSel) return
371
473
  e.stopPropagation()
372
- setDragState({ id: item.id, type: 'move', initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation })
474
+ setDragState({ id: item.id, type: 'move', initX: e.clientX, initY: e.clientY, ...initGeom })
373
475
  }
374
476
  const handles = isSel && (
375
477
  <>
376
- {(['nw', 'ne', 'sw', 'se'] as Corner[]).map(c => (
377
- <CornerHandle key={c} corner={c} scale={scale} onMouseDown={(e) => {
478
+ {RESIZE_HANDLES.map(h => (
479
+ <ResizeHandle key={h} handle={h} scaleX={scaleX} scaleY={scaleY} onMouseDown={(e) => {
378
480
  e.stopPropagation()
379
- setDragState({ id: item.id, type: `resize-${c}`, initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation })
481
+ setDragState({ id: item.id, type: `resize-${h}`, initX: e.clientX, initY: e.clientY, ...initGeom })
380
482
  }} />
381
483
  ))}
382
- <RotateHandle scale={scale} onMouseDown={(e) => {
484
+ <RotateHandle scaleX={scaleX} scaleY={scaleY} onMouseDown={(e) => {
383
485
  e.stopPropagation()
384
486
  const rect = containerRef.current?.getBoundingClientRect()
385
487
  if (!rect) return
386
488
  const cx = rect.left + rect.width * (0.5 + offsetX / 100)
387
489
  const cy = rect.top + rect.height * (0.5 + offsetY / 100)
388
490
  const initAngle = Math.atan2(e.clientY - cy, e.clientX - cx)
389
- setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation, cx, cy, initAngle })
491
+ setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY, ...initGeom, cx, cy, initAngle })
390
492
  }} />
391
493
  </>
392
494
  )
@@ -396,11 +498,11 @@ export default function OverlayItemsLayer({
396
498
  src={fileUrl(item.src)}
397
499
  draggable={false}
398
500
  className="absolute inset-0 w-full h-full pointer-events-none"
399
- style={{ objectFit: item.fit ?? 'cover' }}
501
+ style={{ objectFit: fit }}
400
502
  />
401
503
  {handles}
402
504
  {isSel && onOverlayChange && (
403
- <FitControl value={item.fit ?? 'cover'} scale={scale} onChange={(fit) => onOverlayChange(item.id, { fit })} />
505
+ <FitControl value={fit} scaleX={scaleX} scaleY={scaleY} onChange={(next) => onOverlayChange(item.id, { fit: next })} />
404
506
  )}
405
507
  </div>
406
508
  )
@@ -409,29 +511,68 @@ export default function OverlayItemsLayer({
409
511
  {/* All interactive tracks — in canvas mode this includes track 0; otherwise overlays only */}
410
512
  {interactiveTracks.map((trackItems, trackIdx) =>
411
513
  trackItems.map((item) => {
412
- const visible = currentTime >= item.start && currentTime < item.end
413
- // Pre-mount video items slightly before their start so the frame is ready (no flash)
514
+ // Activation: the shared half-open `start <= t < end` predicate.
515
+ const visible = containsTime(item.start, item.end, currentTime)
516
+ // Presentation-only pre-mount window — see VIDEO_PRELOAD_S above for
517
+ // why it is not (and must not be) a resolver concern. A pre-mounted
518
+ // item still renders at opacity 0 until `visible` flips true.
414
519
  const mounted = item.type === 'video'
415
- ? currentTime >= item.start - VIDEO_PRELOAD_S && currentTime < item.end
520
+ ? containsTime(item.start - VIDEO_PRELOAD_S, item.end, currentTime)
416
521
  : visible
417
522
  if (!mounted) return null
418
523
 
419
524
  const isSel = selectedOverlayId === item.id
420
- const offsetX = (liveOffset?.id === item.id ? liveOffset.x : null) ?? item.offsetX ?? 0
421
- const offsetY = (liveOffset?.id === item.id ? liveOffset.y : null) ?? item.offsetY ?? 0
422
- const scale = (liveScale?.id === item.id ? liveScale.scale : null) ?? item.scale ?? 1
423
- const rotation = (liveRotation?.id === item.id ? liveRotation.rotation : null) ?? item.rotation ?? 0
525
+ // Persisted geometry from the resolver; live drag state layered on top
526
+ // (see the tracks[0] block above for why the override has to win).
527
+ //
528
+ // EVERY item kind animates its position, scale and rotation: since
529
+ // SP9d the renderer compiles a clip's curves into time-varying ffmpeg
530
+ // expressions (encode-segment.js `animatedGeometry`), so a moving clip
531
+ // in this preview is a promise the export now keeps.
532
+ //
533
+ // OPACITY IS THE ONE EXCEPTION, and it is not a cost decision — it is
534
+ // a wall. ffmpeg's `colorchannelmixer` takes its alpha gain `aa` as a
535
+ // <double> and accepts no expression at all, so a clip's opacity curve
536
+ // cannot reach the render in any form. Animating it here would put
537
+ // back exactly the preview/render divergence this package exists to
538
+ // prevent: a fade the viewer sees and the export silently drops. So
539
+ // clips sample the curve for geometry and keep their STATIC opacity.
540
+ //
541
+ // Do not "finish the job" by dropping this override. Overlays are
542
+ // unaffected — they are baked per frame in a browser, where opacity is
543
+ // just another CSS value.
544
+ const animated = geometryAt(item, item.type, currentTime - item.start)
545
+ const g = item.type === 'overlay'
546
+ ? animated
547
+ : { ...animated, opacity: geometryFor(item, item.type).opacity }
548
+ const fit = g.fit ?? 'cover'
549
+ const offsetX = (liveOffset?.id === item.id ? liveOffset.x : null) ?? g.offsetX
550
+ const offsetY = (liveOffset?.id === item.id ? liveOffset.y : null) ?? g.offsetY
551
+ const scale = (liveScale?.id === item.id ? liveScale.scale : null) ?? g.scale
552
+ // Per-axis is what the box actually renders at; `scale` stays the
553
+ // uniform value the drag state carries. `g.scaleX`/`g.scaleY` already
554
+ // fall back to `g.scale`, so a legacy item resolves both to one number.
555
+ const scaleX = (liveScale?.id === item.id ? liveScale.scaleX : null) ?? g.scaleX
556
+ const scaleY = (liveScale?.id === item.id ? liveScale.scaleY : null) ?? g.scaleY
557
+ const rotation = (liveRotation?.id === item.id ? liveRotation.rotation : null) ?? g.rotation
558
+ // Whether the item PERSISTS per-axis scale (vs inheriting both from the
559
+ // legacy uniform `scale`) — decides whether a resize commits per-axis
560
+ // fields; see `onUp` in useDragOverlay.ts.
561
+ const hasPerAxis = item.scaleX != null || item.scaleY != null
562
+ const initGeom = { initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initScaleX: scaleX, initScaleY: scaleY, initHasPerAxis: hasPerAxis, initRotation: rotation }
424
563
 
425
564
  function startMove(e: React.MouseEvent) {
426
565
  if (!isSel) return
427
566
  e.stopPropagation()
428
- setDragState({ id: item.id, type: 'move', initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation })
567
+ setDragState({ id: item.id, type: 'move', initX: e.clientX, initY: e.clientY, ...initGeom })
429
568
  }
430
569
 
431
- function startResize(corner: Corner) {
570
+ // Corner OR edge — `useDragOverlay` reads the suffix and decides
571
+ // between a both-axes and a single-axis gesture.
572
+ function startResize(handle: Corner | Edge) {
432
573
  return (e: React.MouseEvent) => {
433
574
  e.stopPropagation()
434
- setDragState({ id: item.id, type: `resize-${corner}`, initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation })
575
+ setDragState({ id: item.id, type: `resize-${handle}`, initX: e.clientX, initY: e.clientY, ...initGeom })
435
576
  }
436
577
  }
437
578
 
@@ -442,7 +583,7 @@ export default function OverlayItemsLayer({
442
583
  const cx = rect.left + rect.width * (0.5 + offsetX / 100)
443
584
  const cy = rect.top + rect.height * (0.5 + offsetY / 100)
444
585
  const initAngle = Math.atan2(e.clientY - cy, e.clientX - cx)
445
- setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation, cx, cy, initAngle })
586
+ setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY, ...initGeom, cx, cy, initAngle })
446
587
  }
447
588
 
448
589
  // Double-click a selected JSX overlay opens the props dialog (owned by
@@ -453,28 +594,58 @@ export default function OverlayItemsLayer({
453
594
  if (item.type === 'overlay' && item.src) onEditOverlay?.(item.id)
454
595
  }
455
596
 
456
- // zIndex: canvas mode track 0 sits just above the play-toggle div (10), others stack above
597
+ // zIndex derives from the same back-to-front ordering the resolver
598
+ // defines (`byTrackIdx`: lower trackIdx = further back = composited
599
+ // first), but the mapping onto CSS stacking contexts stays local —
600
+ // `trackIdx` here indexes `interactiveTracks`, which in non-canvas mode
601
+ // is `project.tracks.slice(1)`, and the base offsets exist only to clear
602
+ // the play-toggle div (z=10). Canvas mode track 0 sits just above it,
603
+ // others stack above.
457
604
  const zIndex = isCanvasProject ? trackIdx + 11 : trackIdx + 12
458
605
 
606
+ // PARITY-CRITICAL: `render/test/overlay-transform-parity.test.mjs`
607
+ // hand-mirrors this exact `transform`/`transformOrigin`/`opacity`
608
+ // template (its `previewStyle`, ~:56-63) to prove the render bake
609
+ // produces the same CSS string this preview does — it cannot import
610
+ // this .tsx file into its plain node:test runner, so it transcribes
611
+ // instead. This file's own suite
612
+ // (preview/__tests__/OverlayItemsLayer.keyframes.test.tsx, the
613
+ // "pins the exact preview template" test) pins the SAME literal
614
+ // template against what this component actually renders, so a drift
615
+ // here fails THAT test even though the render-side test can't see
616
+ // this file at all. Change this template, update both.
617
+ //
618
+ // The template ships in FIVE places, two real and three transcribed:
619
+ // this line, the tracks[0] block above, the keyframes suite's
620
+ // `previewStyle`, render/bundle.js's overlay bake, and the render
621
+ // parity suite's `previewStyle`. The render-side pair is mid-migration
622
+ // onto the two-argument `scale(sx, sy)` form under its own slice, so
623
+ // it still transcribes the one-argument form for now — that gap is
624
+ // known and tracked, not a drift to "fix" from here.
459
625
  const wrapperStyle: React.CSSProperties = {
460
- transform: `translate(${offsetX}%, ${offsetY}%) rotate(${rotation}deg) scale(${scale})`,
626
+ transform: `translate(${offsetX}%, ${offsetY}%) rotate(${rotation}deg) scale(${scaleX}, ${scaleY})`,
461
627
  transformOrigin: 'center center',
462
628
  zIndex,
463
- opacity: item.opacity ?? 1,
629
+ opacity: g.opacity,
630
+ // Selection box. NOT part of the parity template above — the render
631
+ // bake has no selection state to reproduce — so it is appended
632
+ // here, after the three transcribed properties, and only when the
633
+ // item is actually selected.
634
+ ...(isSel ? { outline: SELECTION_OUTLINE } : null),
464
635
  }
465
636
 
466
637
  const wrapperClass = `absolute inset-0 ${
467
638
  isSel
468
- ? `${dragState?.type === 'move' ? 'cursor-grabbing' : 'cursor-grab'} ring-1 ring-inset ring-amber-400/40`
639
+ ? (dragState?.type === 'move' ? 'cursor-grabbing' : 'cursor-grab')
469
640
  : 'pointer-events-none'
470
641
  }`
471
642
 
472
643
  const handles = isSel && (
473
644
  <>
474
- {(['nw', 'ne', 'sw', 'se'] as Corner[]).map(c => (
475
- <CornerHandle key={c} corner={c} scale={scale} onMouseDown={startResize(c)} />
645
+ {RESIZE_HANDLES.map(h => (
646
+ <ResizeHandle key={h} handle={h} scaleX={scaleX} scaleY={scaleY} onMouseDown={startResize(h)} />
476
647
  ))}
477
- <RotateHandle scale={scale} onMouseDown={startRotate} />
648
+ <RotateHandle scaleX={scaleX} scaleY={scaleY} onMouseDown={startRotate} />
478
649
  </>
479
650
  )
480
651
 
@@ -486,11 +657,11 @@ export default function OverlayItemsLayer({
486
657
  src={fileUrl(item.src)}
487
658
  draggable={false}
488
659
  className="absolute inset-0 w-full h-full pointer-events-none"
489
- style={{ objectFit: item.fit ?? 'cover' }}
660
+ style={{ objectFit: fit }}
490
661
  />
491
662
  {handles}
492
663
  {isSel && onOverlayChange && (
493
- <FitControl value={item.fit ?? 'cover'} scale={scale} onChange={(fit) => onOverlayChange(item.id, { fit })} />
664
+ <FitControl value={fit} scaleX={scaleX} scaleY={scaleY} onChange={(next) => onOverlayChange(item.id, { fit: next })} />
494
665
  )}
495
666
  </div>
496
667
  )
@@ -501,10 +672,38 @@ export default function OverlayItemsLayer({
501
672
  return (
502
673
  <div key={item.id} className={wrapperClass} style={wrapperStyle} onMouseDown={startMove}>
503
674
  <OverlayVideo
504
- src={fileUrl(item.nobg_preview_src ?? item.src)}
675
+ // This is a THIRD src-precedence chain, deliberately not
676
+ // @bycrux/timeline-core's `playbackSrcFor` (see
677
+ // PreviewPlayer.tsx / useVideoPlayback.ts for that one): an
678
+ // overlay-track video loads the ORIGINAL src (skipping
679
+ // `normalizedSrc` entirely) and pairs it with a raw,
680
+ // un-rebased `inPoint`. Switching this to
681
+ // `playbackSrcFor(item, 'preview')` would add the
682
+ // `normalizedSrc` tier and require rebasing `inPoint` to
683
+ // match — an unbudgeted behavior change for this item
684
+ // class. Out of scope for SP2; owned by SP4.
685
+ //
686
+ // SP3 adds `proxySrc` as a middle tier (between
687
+ // `nobg_preview_src` and `src`), same precedence order as
688
+ // the main chain. `proxySrc` is full-source like `src`
689
+ // itself — no window, no rebase — so it slots in without
690
+ // disturbing the raw, un-rebased `inPoint` this chain
691
+ // already passes through. The tier is capability/failure
692
+ // gated (SP3 fix B2) exactly like the main chain's.
693
+ src={fileUrl(item.nobg_preview_src
694
+ ?? (isProxyUsable(item.proxySrc) ? item.proxySrc : undefined)
695
+ ?? item.src)}
696
+ onSrcError={() => {
697
+ if (!item.nobg_preview_src && isProxyUsable(item.proxySrc) && item.proxySrc) {
698
+ console.warn(`[montaj] overlay proxy failed to decode — falling back to the master: ${item.proxySrc}`)
699
+ markProxyFailed(item.proxySrc)
700
+ bumpProxyFail()
701
+ }
702
+ }}
505
703
  currentTime={currentTime}
506
704
  itemStart={item.start}
507
705
  inPoint={item.inPoint ?? 0}
706
+ speed={item.speed ?? 1}
508
707
  isPlaying={isPlaying}
509
708
  muted={item.muted}
510
709
  visible={visible}
@@ -569,7 +768,9 @@ export default function OverlayItemsLayer({
569
768
  return (
570
769
  <div
571
770
  key={item.id}
572
- className={`absolute ${isSel ? 'cursor-grab ring-1 ring-amber-400/40' : 'pointer-events-none'} ${posClass[pos] ?? posClass['bottom-left']}`}
771
+ // The selection outline rides on `wrapperStyle` (shared with the
772
+ // branches above), so only the cursor differs from unselected.
773
+ className={`absolute ${isSel ? 'cursor-grab' : 'pointer-events-none'} ${posClass[pos] ?? posClass['bottom-left']}`}
573
774
  style={wrapperStyle}
574
775
  onMouseDown={startMove}
575
776
  >
@@ -584,26 +785,35 @@ export default function OverlayItemsLayer({
584
785
  })
585
786
  )}
586
787
 
788
+ {/* Snap guides. All of these are selection chrome, so they read the same
789
+ token as the box and handles. The faint reference frame gets its
790
+ fade from element `opacity` rather than a translucent colour: the
791
+ token is an opaque colour string, and this codebase deliberately
792
+ avoids `color-mix` (see ControlsInfoModal.tsx ~:130). */}
587
793
  {/* Center snap guide lines */}
588
794
  {dragState?.type === 'move' && snapGuides.x && (
589
- <div className="absolute top-0 bottom-0 left-1/2 w-px bg-amber-400 pointer-events-none z-50"
590
- style={{ transform: 'translateX(-50%)' }} />
795
+ <div className="absolute top-0 bottom-0 left-1/2 w-px pointer-events-none z-50"
796
+ style={{ backgroundColor: SELECTION, transform: 'translateX(-50%)' }} />
591
797
  )}
592
798
  {dragState?.type === 'move' && snapGuides.y && (
593
- <div className="absolute left-0 right-0 top-1/2 h-px bg-amber-400 pointer-events-none z-50"
594
- style={{ transform: 'translateY(-50%)' }} />
799
+ <div className="absolute left-0 right-0 top-1/2 h-px pointer-events-none z-50"
800
+ style={{ backgroundColor: SELECTION, transform: 'translateY(-50%)' }} />
595
801
  )}
596
802
  {/* Edge guide lines — always visible during a move drag as reference frame */}
597
- {dragState?.type === 'move' && <div className="absolute top-0 bottom-0 left-0 w-px bg-amber-400/30 pointer-events-none z-50" />}
598
- {dragState?.type === 'move' && <div className="absolute top-0 bottom-0 right-0 w-px bg-amber-400/30 pointer-events-none z-50" />}
599
- {dragState?.type === 'move' && <div className="absolute left-0 right-0 top-0 h-px bg-amber-400/30 pointer-events-none z-50" />}
600
- {dragState?.type === 'move' && <div className="absolute left-0 right-0 bottom-0 h-px bg-amber-400/30 pointer-events-none z-50" />}
803
+ {dragState?.type === 'move' && <div className="absolute top-0 bottom-0 left-0 w-px pointer-events-none z-50" style={{ backgroundColor: SELECTION, opacity: 0.3 }} />}
804
+ {dragState?.type === 'move' && <div className="absolute top-0 bottom-0 right-0 w-px pointer-events-none z-50" style={{ backgroundColor: SELECTION, opacity: 0.3 }} />}
805
+ {dragState?.type === 'move' && <div className="absolute left-0 right-0 top-0 h-px pointer-events-none z-50" style={{ backgroundColor: SELECTION, opacity: 0.3 }} />}
806
+ {dragState?.type === 'move' && <div className="absolute left-0 right-0 bottom-0 h-px pointer-events-none z-50" style={{ backgroundColor: SELECTION, opacity: 0.3 }} />}
601
807
  {/* Edge snap highlight — brighten when snapping to an edge */}
602
- {dragState?.type === 'move' && snapGuides.left && <div className="absolute top-0 bottom-0 left-0 w-px bg-amber-400 pointer-events-none z-50" />}
603
- {dragState?.type === 'move' && snapGuides.right && <div className="absolute top-0 bottom-0 right-0 w-px bg-amber-400 pointer-events-none z-50" />}
604
- {dragState?.type === 'move' && snapGuides.top && <div className="absolute left-0 right-0 top-0 h-px bg-amber-400 pointer-events-none z-50" />}
605
- {dragState?.type === 'move' && snapGuides.bottom && <div className="absolute left-0 right-0 bottom-0 h-px bg-amber-400 pointer-events-none z-50" />}
606
- {/* Rotation snap guide — line through center at the snapped angle */}
808
+ {dragState?.type === 'move' && snapGuides.left && <div className="absolute top-0 bottom-0 left-0 w-px pointer-events-none z-50" style={{ backgroundColor: SELECTION }} />}
809
+ {dragState?.type === 'move' && snapGuides.right && <div className="absolute top-0 bottom-0 right-0 w-px pointer-events-none z-50" style={{ backgroundColor: SELECTION }} />}
810
+ {dragState?.type === 'move' && snapGuides.top && <div className="absolute left-0 right-0 top-0 h-px pointer-events-none z-50" style={{ backgroundColor: SELECTION }} />}
811
+ {dragState?.type === 'move' && snapGuides.bottom && <div className="absolute left-0 right-0 bottom-0 h-px pointer-events-none z-50" style={{ backgroundColor: SELECTION }} />}
812
+ {/* Rotation snap guide — line through center at the snapped angle.
813
+ `stroke` goes through the CSS `style` prop, NOT the SVG presentation
814
+ attribute: `var()` is only resolved by the CSS cascade, so
815
+ stroke="var(--editor-selection)" would be parsed as an unknown paint
816
+ and drop the line entirely. */}
607
817
  {dragState?.type === 'rotate' && snapRotation !== null && (
608
818
  <div className="absolute inset-0 pointer-events-none z-50">
609
819
  <svg width="100%" height="100%" overflow="visible">
@@ -611,13 +821,15 @@ export default function OverlayItemsLayer({
611
821
  x1="50%" y1="50%"
612
822
  x2={`calc(50% + 200% * ${Math.cos((snapRotation - 90) * Math.PI / 180)})`}
613
823
  y2={`calc(50% + 200% * ${Math.sin((snapRotation - 90) * Math.PI / 180)})`}
614
- stroke="rgb(251 191 36)" strokeWidth="1" strokeDasharray="4 3" opacity="0.8"
824
+ style={{ stroke: SELECTION }}
825
+ strokeWidth="1" strokeDasharray="4 3" opacity="0.8"
615
826
  />
616
827
  <line
617
828
  x1="50%" y1="50%"
618
829
  x2={`calc(50% - 200% * ${Math.cos((snapRotation - 90) * Math.PI / 180)})`}
619
830
  y2={`calc(50% - 200% * ${Math.sin((snapRotation - 90) * Math.PI / 180)})`}
620
- stroke="rgb(251 191 36)" strokeWidth="1" strokeDasharray="4 3" opacity="0.8"
831
+ style={{ stroke: SELECTION }}
832
+ strokeWidth="1" strokeDasharray="4 3" opacity="0.8"
621
833
  />
622
834
  </svg>
623
835
  </div>