@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,111 @@
1
+ /**
2
+ * The page's ONE Web Audio context, and the gesture-anchored resume that keeps
3
+ * it running.
4
+ *
5
+ * SP4 T4 **moved** this out of `useVideoPlayback.ts` — it is not a copy. Both
6
+ * playback paths need the identical context and the identical resume rule:
7
+ *
8
+ * - the legacy `<video>` player wires every slot and every audio lane
9
+ * through `MediaElementSource → GainNode → ctx.destination`, so a
10
+ * suspended context silently kills audio *and* video frame production;
11
+ * - the WebCodecs engine (`engine/audio-clock.ts`) drives its MASTER CLOCK
12
+ * off an `AudioWorklet` on the same context, so a suspended context stalls
13
+ * the clock and therefore stalls *painting* — the same failure with a
14
+ * wider blast radius.
15
+ *
16
+ * Two copies of this logic would be a textbook divergence-registry hazard
17
+ * (two implementations of one rule, drifting silently), which is why the
18
+ * extraction is a hard requirement of the engine work rather than a tidy-up.
19
+ *
20
+ * Nothing here closes the context: it is window-scoped on purpose (see
21
+ * `getSharedAudioContext`).
22
+ */
23
+
24
+ /** Typed extension for the shared AudioContext cached on window. */
25
+ export interface MontajWindow {
26
+ __montajSharedCtx?: AudioContext
27
+ }
28
+
29
+ /**
30
+ * CRITICAL: ALL video slots AND ALL audio tracks AND the engine's master clock
31
+ * share the SAME AudioContext. Fresh AudioContexts start suspended and require
32
+ * a user gesture to resume. Creating separate contexts (one per slot, or a
33
+ * separate one for audio tracks) means those born inside a useEffect have no
34
+ * user-gesture activation — they stay suspended → silent. Worse, video frame
35
+ * production is gated on the wired AudioContext clock running, so a suspended
36
+ * context with a wired <video> produces "video plays but no frames render"
37
+ * after a hard refresh.
38
+ *
39
+ * Stash the single shared context on `window` so it survives strict-mode
40
+ * remounts and is reused across audio tracks, video slots, engine sessions,
41
+ * and re-mounts.
42
+ */
43
+ export function getSharedAudioContext(): AudioContext {
44
+ const w = window as Window & MontajWindow
45
+ // If the cached context is closed (shouldn't happen, but defensive), recreate.
46
+ if (!w.__montajSharedCtx || w.__montajSharedCtx.state === 'closed') {
47
+ w.__montajSharedCtx = new AudioContext()
48
+ }
49
+ return w.__montajSharedCtx
50
+ }
51
+
52
+ /**
53
+ * Read the shared context WITHOUT creating one. Callers that need to sample a
54
+ * live property off the context (latency, state) but must not force its
55
+ * creation — because creation off the gesture stack is precisely the failure
56
+ * mode `getSharedAudioContext` exists to make explicit — use this instead.
57
+ * Returns undefined until something on the gesture stack has minted the ctx.
58
+ */
59
+ export function peekSharedAudioContext(): AudioContext | undefined {
60
+ return (window as Window & MontajWindow).__montajSharedCtx
61
+ }
62
+
63
+ /**
64
+ * MUST be called from inside a user-gesture handler (keydown, click, etc.).
65
+ * Browsers credit a `resume()` call as gesture-driven only when it happens
66
+ * synchronously inside a gesture-rooted call stack. Calling resume() from
67
+ * a useEffect or a setTimeout silently fails — the context stays suspended.
68
+ *
69
+ * Symptom of a suspended context with a wired <video>: video appears to
70
+ * play (paused=false, currentTime advances internally per the DOM clock)
71
+ * but no frames render and no audio plays — the entire pipeline is gated
72
+ * on the AudioContext clock running. This bites specifically after a page
73
+ * refresh, because SPA navigation carries gesture activation across pages
74
+ * but a hard reload does not.
75
+ *
76
+ * The engine path has the same requirement for a different reason: its master
77
+ * clock counts frames an `AudioWorklet` has actually rendered, and a suspended
78
+ * context renders none — the clock (and the canvas painting that follows it)
79
+ * freezes until something resumes it from a gesture.
80
+ *
81
+ * Deliberately does NOT create the context: this is only ever called from a
82
+ * gesture that is *also* about to use an already-created context, and creating
83
+ * one here would hide the "nobody has wired audio yet" case.
84
+ */
85
+ export function resumeAudioContextFromGesture() {
86
+ const w = window as Window & MontajWindow
87
+ const ctx: AudioContext | undefined = w.__montajSharedCtx
88
+ if (ctx && ctx.state === 'suspended') {
89
+ // Fire-and-forget; resume() returns a Promise but the gesture-credit
90
+ // happens at the synchronous call site, not when the promise resolves.
91
+ ctx.resume().catch(() => {})
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Seconds between the moment a sample is handed to the output device and the
97
+ * moment it becomes audible: the sum of `outputLatency` (the device queue) and
98
+ * `baseLatency` (the graph's own processing lead). The engine's master clock
99
+ * counts *frames consumed* by `process()` — a frame the clock has counted is
100
+ * still `latencySeconds()` away from the ear, and the `<video>` fallback's
101
+ * `currentTime` has the same gap on the same graph, so the value bridged to
102
+ * the painter is `now() - latencySeconds(ctx)` on both paths.
103
+ *
104
+ * Read live on every emit (device switches can change it), clamp ≥ 0, and
105
+ * tolerate missing fields on non-Chromium contexts / test stubs.
106
+ */
107
+ export function latencySeconds(ctx: Pick<AudioContext, 'outputLatency' | 'baseLatency'>): number {
108
+ const out = Number.isFinite(ctx.outputLatency) ? ctx.outputLatency : 0
109
+ const base = Number.isFinite(ctx.baseLatency) ? ctx.baseLatency : 0
110
+ return Math.max(0, out + base)
111
+ }
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Pure drag maths for caption-segment positioning in the preview.
2
+ * Pure drag maths — and the one DOM measurement they depend on — for
3
+ * caption-segment positioning in the preview. Everything here is React-free so
4
+ * it can be unit-tested without mounting CaptionPreview.
3
5
  *
4
6
  * Why this is NOT `useDragOverlay` generalised
5
7
  * --------------------------------------------
@@ -199,3 +201,91 @@ export function captionDragPatch(
199
201
  ? { offsetX: geom.offsetX, offsetY: geom.offsetY }
200
202
  : { scale: geom.scale }
201
203
  }
204
+
205
+ // ── Measuring where a caption actually paints ───────────────────────────────
206
+
207
+ /** A rectangle in client (screen) coordinates. */
208
+ export interface CaptionContentRect {
209
+ left: number
210
+ top: number
211
+ width: number
212
+ height: number
213
+ }
214
+
215
+ /**
216
+ * The element a measurement should be scoped to: the subtree a caption
217
+ * template marked with `data-caption-id="<segmentId>"`, or `root` itself when
218
+ * no id was asked for or no marker was found.
219
+ *
220
+ * Matched by walking `[data-caption-id]` and comparing the attribute rather
221
+ * than building a `[data-caption-id="…"]` selector, so a segment id containing
222
+ * a quote or backslash (project.json is hand-editable) can't throw a selector
223
+ * SyntaxError. The subtree is a handful of nodes, so the walk is free.
224
+ */
225
+ function scopeForSegment(root: HTMLElement, segmentId?: string): HTMLElement {
226
+ if (!segmentId) return root
227
+ for (const el of root.querySelectorAll<HTMLElement>('[data-caption-id]')) {
228
+ if (el.getAttribute('data-caption-id') === segmentId) return el
229
+ }
230
+ return root
231
+ }
232
+
233
+ /**
234
+ * Union of the client rects of every painted text run (and replaced element)
235
+ * under `root` — or, when `segmentId` is given, under just that segment's
236
+ * marked subtree.
237
+ *
238
+ * Deliberately NOT `root.getBoundingClientRect()`: the template's own outermost
239
+ * element is a frame-sized `position: fixed; inset: 0` box (captionOuterStyle),
240
+ * so its rect — and the rect of any wrapper we put around it — is the entire
241
+ * preview, which would put the selection box around the whole frame. Walking to
242
+ * the text nodes is the only markup-agnostic way to find where the caption
243
+ * actually paints, and it costs nothing on a subtree this small.
244
+ *
245
+ * WHY `segmentId`: the templates draw EVERY caption active at an instant now
246
+ * that captions have lanes, so an unscoped walk would union two unrelated
247
+ * captions into one selection box spanning both. Naming the target segment
248
+ * narrows the walk to that caption alone.
249
+ *
250
+ * FALLBACK, deliberately: a template with no `data-caption-id` anywhere — one
251
+ * written before the attribute existed, or supplied by a host — measures the
252
+ * whole root, i.e. exactly the pre-lane behaviour. A missing marker degrades to
253
+ * the old selection box, never to no selection box.
254
+ *
255
+ * Returns null when nothing is painted (e.g. `pop` renders no word between two
256
+ * word windows), which is the signal to hide the selection box entirely.
257
+ */
258
+ export function measureCaptionContentRect(
259
+ root: HTMLElement,
260
+ segmentId?: string,
261
+ ): CaptionContentRect | null {
262
+ const scope = scopeForSegment(root, segmentId)
263
+ const doc = scope.ownerDocument
264
+ const walker = doc.createTreeWalker(scope, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT)
265
+ const range = doc.createRange()
266
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity
267
+
268
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
269
+ let rect: DOMRect | null = null
270
+ if (node.nodeType === Node.TEXT_NODE) {
271
+ if (!node.nodeValue?.trim()) continue
272
+ range.selectNodeContents(node)
273
+ rect = range.getBoundingClientRect()
274
+ } else {
275
+ // Replaced elements paint without text nodes. Everything else is a
276
+ // container whose box is either the frame-sized outer wrapper or a
277
+ // full-width anchor box — including them would defeat the point.
278
+ const tag = (node as Element).tagName?.toUpperCase()
279
+ if (tag !== 'IMG' && tag !== 'SVG' && tag !== 'VIDEO' && tag !== 'CANVAS') continue
280
+ rect = (node as Element).getBoundingClientRect()
281
+ }
282
+ if (!rect || (!rect.width && !rect.height)) continue
283
+ left = Math.min(left, rect.left)
284
+ top = Math.min(top, rect.top)
285
+ right = Math.max(right, rect.right)
286
+ bottom = Math.max(bottom, rect.bottom)
287
+ }
288
+
289
+ if (!Number.isFinite(left)) return null
290
+ return { left, top, width: right - left, height: bottom - top }
291
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * SP3 fix B2 — proxy playback capability gate + per-session failure suppression.
3
+ *
4
+ * Editing proxies are H.264+Opus in MP4. Not every browser decodes that
5
+ * (Opus-in-MP4 support is not universal — a browser can decode H.264 and
6
+ * Opus separately yet still fail on the muxed combination), and a
7
+ * `<video>` element given an undecodable src fails SILENTLY — a black preview
8
+ * with no event the user can act on. Two defenses, both editor-side so
9
+ * timeline-core stays pure (no environment probing in the resolver):
10
+ *
11
+ * 1. Capability gate: probed once per session. Unsupported ⇒ `gateProxy()`
12
+ * strips `proxySrc` from the item BEFORE src selection, so the tier
13
+ * chain falls through to normalizedSrc/src exactly as if no proxy
14
+ * existed.
15
+ * 2. Failure suppression: when a proxy that passed the probe still fails to
16
+ * decode (corrupt file, dangling pointer after an out-of-band delete),
17
+ * the error handler marks that proxySrc failed for the rest of the
18
+ * session and the player reloads the clip without its proxy tier.
19
+ *
20
+ * jsdom note: the vitest environment stubs `canPlayType` to report support
21
+ * (see src/test-setup.ts) so the existing proxy-selection tests keep meaning;
22
+ * the unsupported path is tested via `__setProxySupportForTests`.
23
+ */
24
+
25
+ // avc1.640028 = High profile @ level 4.0 — libx264's actual default output
26
+ // (High profile; 720p above 30fps needs level 4.0), not Main@3.1. Pinning
27
+ // -profile:v main on the encoder to match a cheaper string would cost
28
+ // bitrate for no benefit, so the declared codec follows the encoder instead.
29
+ const PROXY_MIME = 'video/mp4; codecs="avc1.640028, opus"'
30
+
31
+ let supportedCache: boolean | null = null
32
+
33
+ export function proxyPlaybackSupported(): boolean {
34
+ if (supportedCache !== null) return supportedCache
35
+ if (typeof document === 'undefined') {
36
+ supportedCache = false
37
+ return supportedCache
38
+ }
39
+ let ok = false
40
+ try {
41
+ const ms = (globalThis as { MediaSource?: { isTypeSupported?: (t: string) => boolean } }).MediaSource
42
+ if (ms?.isTypeSupported?.(PROXY_MIME)) {
43
+ ok = true
44
+ } else {
45
+ ok = document.createElement('video').canPlayType(PROXY_MIME) !== ''
46
+ }
47
+ } catch {
48
+ ok = false
49
+ }
50
+ supportedCache = ok
51
+ return supportedCache
52
+ }
53
+
54
+ /** Test hook: force (true/false) or reset (null) the cached probe result. */
55
+ export function __setProxySupportForTests(value: boolean | null): void {
56
+ supportedCache = value
57
+ }
58
+
59
+ const failedProxies = new Set<string>()
60
+
61
+ /** Record a proxy file that failed to decode this session. */
62
+ export function markProxyFailed(proxySrc: string): void {
63
+ failedProxies.add(proxySrc)
64
+ }
65
+
66
+ /** True when this item's proxy is present, supported, and not failed. */
67
+ export function isProxyUsable(proxySrc: string | undefined): boolean {
68
+ return !!proxySrc && proxyPlaybackSupported() && !failedProxies.has(proxySrc)
69
+ }
70
+
71
+ /** Test hook: clear the per-session failure set. */
72
+ export function __clearFailedProxiesForTests(): void {
73
+ failedProxies.clear()
74
+ }
75
+
76
+ /**
77
+ * Return the item as-is when its proxy is usable, otherwise a shallow copy
78
+ * with `proxySrc` removed — the input object is never mutated (it is shared
79
+ * project state).
80
+ */
81
+ export function gateProxy<T extends { proxySrc?: string }>(item: T): T {
82
+ if (!item.proxySrc || isProxyUsable(item.proxySrc)) return item
83
+ const gated = { ...item }
84
+ delete (gated as { proxySrc?: string }).proxySrc
85
+ return gated
86
+ }
@@ -10,9 +10,15 @@
10
10
  // the container's own (frame) size, and scale() around center.
11
11
 
12
12
  import type { CSSProperties } from 'react'
13
+ import { geometryFor, toCssBoxPct } from '@bycrux/timeline-core'
13
14
 
14
15
  export interface VideoTransform {
16
+ /** The legacy UNIFORM knob, and still the fallback for both axes. */
15
17
  scale?: number
18
+ /** Multiplier on WIDTH. Absent ⇒ falls back to `scale` (then 1). */
19
+ scaleX?: number
20
+ /** Multiplier on HEIGHT. Absent ⇒ falls back to `scale` (then 1). */
21
+ scaleY?: number
16
22
  offsetX?: number // percent of frame width
17
23
  offsetY?: number // percent of frame height
18
24
  }
@@ -20,25 +26,29 @@ export interface VideoTransform {
20
26
  // CSS transform for a frame-sized container wrapping the <video>. translate() %
21
27
  // is relative to the container (= frame), matching the renderer's frame-percent
22
28
  // offset; scale() is around center, matching the renderer's centered box.
29
+ //
30
+ // The two-argument `scale(sx, sy)` is a strict superset of the old one-argument
31
+ // form: a legacy item carrying only `scale` resolves both axes to that same
32
+ // number, so it renders the identical box it always did.
23
33
  export function videoTransformContainerStyle(t: VideoTransform): CSSProperties {
24
- const s = t.scale ?? 1
34
+ const sx = t.scaleX ?? t.scale ?? 1
35
+ const sy = t.scaleY ?? t.scale ?? 1
25
36
  const ox = t.offsetX ?? 0
26
37
  const oy = t.offsetY ?? 0
27
- if (s === 1 && ox === 0 && oy === 0) return {}
28
- return { transform: `translate(${ox}%, ${oy}%) scale(${s})`, transformOrigin: 'center center' }
38
+ // Identity on BOTH axes and no offset — emit nothing rather than an inert
39
+ // CSS transform (which would otherwise create a containing block and a
40
+ // compositing layer for every unmodified clip).
41
+ if (sx === 1 && sy === 1 && ox === 0 && oy === 0) return {}
42
+ return { transform: `translate(${ox}%, ${oy}%) scale(${sx}, ${sy})`, transformOrigin: 'center center' }
29
43
  }
30
44
 
31
45
  // The transform box as a frame-relative % rect (left/top/width/height in %).
32
46
  // This is the canvas-aspect box the cropped video is contained within; the crop
33
47
  // handles for the on-canvas transform are drawn on it.
48
+ //
49
+ // Per-axis scale needs no handling here: `geometryFor` resolves scaleX/scaleY
50
+ // (falling back to `scale`) and `toCssBoxPct` takes width/left from the X scale
51
+ // and height/top from the Y scale, so this inherits the split unchanged.
34
52
  export function videoTransformBoxPct(t: VideoTransform): { left: number; top: number; width: number; height: number } {
35
- const s = t.scale ?? 1
36
- const ox = t.offsetX ?? 0
37
- const oy = t.offsetY ?? 0
38
- return {
39
- width: s * 100,
40
- height: s * 100,
41
- left: ((1 - s) / 2) * 100 + ox,
42
- top: ((1 - s) / 2) * 100 + oy,
43
- }
53
+ return toCssBoxPct(geometryFor(t, 'video'))
44
54
  }
@@ -2,7 +2,14 @@ import { useEffect, useRef, useState } from 'react'
2
2
  import type { VisualItem } from '../../schema'
3
3
 
4
4
  export type Corner = 'nw' | 'ne' | 'sw' | 'se'
5
- export type DragType = 'move' | `resize-${Corner}` | 'rotate'
5
+ /**
6
+ * Single-axis resize handles. A corner drag scales BOTH axes together (the
7
+ * legacy uniform gesture); an edge drag scales exactly one — `e`/`w` the X
8
+ * axis, `n`/`s` the Y axis. The handles themselves are drawn by a later slice;
9
+ * the hook understands the gesture already so the two can land independently.
10
+ */
11
+ export type Edge = 'n' | 's' | 'e' | 'w'
12
+ export type DragType = 'move' | `resize-${Corner}` | `resize-${Edge}` | 'rotate'
6
13
 
7
14
  // Shared shape for `onOverlayChange` across the preview layer: drag/resize/rotate
8
15
  // gestures (useDragOverlay) only ever populate the geometric subset; content-editing
@@ -11,7 +18,12 @@ export type DragType = 'move' | `resize-${Corner}` | 'rotate'
11
18
  export interface OverlayChanges {
12
19
  offsetX?: number
13
20
  offsetY?: number
21
+ /** The legacy UNIFORM scale. Still written by every corner drag. */
14
22
  scale?: number
23
+ /** Per-axis width multiplier. Absent ⇒ the item keeps falling back to `scale`. */
24
+ scaleX?: number
25
+ /** Per-axis height multiplier. Absent ⇒ the item keeps falling back to `scale`. */
26
+ scaleY?: number
15
27
  rotation?: number
16
28
  fit?: 'cover' | 'contain' | 'fill'
17
29
  sourceCrop?: VisualItem['sourceCrop']
@@ -35,7 +47,20 @@ interface DragState {
35
47
  initY: number
36
48
  initOffsetX: number
37
49
  initOffsetY: number
50
+ /** The RESOLVED uniform scale at gesture start. */
38
51
  initScale: number
52
+ /**
53
+ * The RESOLVED per-axis scales at gesture start. Both equal `initScale` on a
54
+ * uniform item, so omitting them (they default to `initScale`) is always the
55
+ * legacy behavior.
56
+ */
57
+ initScaleX?: number
58
+ initScaleY?: number
59
+ /**
60
+ * Whether the ITEM ITSELF carries `scaleX`/`scaleY`, as opposed to inheriting
61
+ * both from the legacy uniform `scale`. Decides the commit shape — see `onUp`.
62
+ */
63
+ initHasPerAxis?: boolean
39
64
  initRotation: number
40
65
  // rotate-specific: center of element in page coords and initial angle
41
66
  cx?: number
@@ -50,7 +75,10 @@ export function useDragOverlay(
50
75
  const [dragState, setDragState] = useState<DragState | null>(null)
51
76
 
52
77
  const [liveOffset, setLiveOffset] = useState<{ id: string; x: number; y: number } | null>(null)
53
- const [liveScale, setLiveScale] = useState<{ id: string; scale: number } | null>(null)
78
+ // `scale` is the uniform value (only a CORNER drag moves it); `scaleX`/`scaleY`
79
+ // are what the preview actually renders, so an edge drag can move one axis
80
+ // without the other. On a uniform item all three stay equal.
81
+ const [liveScale, setLiveScale] = useState<{ id: string; scale: number; scaleX: number; scaleY: number } | null>(null)
54
82
  const [liveRotation, setLiveRotation] = useState<{ id: string; rotation: number } | null>(null)
55
83
  const liveOffsetRef = useRef<typeof liveOffset>(null)
56
84
  const liveScaleRef = useRef<typeof liveScale>(null)
@@ -80,13 +108,18 @@ export function useDragOverlay(
80
108
  const rawX = dragState.initOffsetX + dx
81
109
  const rawY = dragState.initOffsetY + dy
82
110
 
83
- // Edge snap positions depend on scale.
84
- // Element is inset-0 (fills container) then scaled from center.
85
- // Left edge hits screen left / right edge hits screen right when offsetX = ±(0.5 - s/2)*100.
86
- // For scale=1 this is 0 (same as center snap), so edge snap only activates for scaled-down items.
87
- const s = dragState.initScale
88
- const edgeX = (0.5 - s / 2) * 100 // offset where element edge meets screen edge
89
- const edgeY = (0.5 - s / 2) * 100
111
+ // Edge snap positions depend on scale, PER AXIS.
112
+ // Element is inset-0 (fills container) then scaled from center, so its half-width is
113
+ // sx/2 of the frame and its half-height sy/2.
114
+ // Left edge hits screen left / right edge hits screen right when offsetX = ±(0.5 - sx/2)*100;
115
+ // top/bottom likewise at ±(0.5 - sy/2)*100.
116
+ // For a scale of 1 that is 0 (same as center snap), so edge snap only activates for an axis
117
+ // that is scaled DOWN — and with a non-uniform item the axes decide independently, e.g. a
118
+ // full-width-but-squat item gets top/bottom edge snap and no left/right snap at all.
119
+ const sx = dragState.initScaleX ?? dragState.initScale
120
+ const sy = dragState.initScaleY ?? dragState.initScale
121
+ const edgeX = (0.5 - sx / 2) * 100 // offset where the element's left/right edge meets the frame's
122
+ const edgeY = (0.5 - sy / 2) * 100 // offset where the element's top/bottom edge meets the frame's
90
123
  const hasEdgeX = edgeX > SNAP_THRESHOLD // skip if too close to center snap
91
124
  const hasEdgeY = edgeY > SNAP_THRESHOLD
92
125
 
@@ -140,13 +173,37 @@ export function useDragOverlay(
140
173
  setLiveRotation(next)
141
174
  liveRotationRef.current = next
142
175
  } else {
143
- // Resize from corner
144
- const corner = dragState.type.slice(7) as Corner // 'resize-se' → 'se'
145
- const sx = corner.includes('e') ? 1 : -1
146
- const sy = corner.includes('s') ? 1 : -1
147
- const delta = (dx * sx + dy * sy) / 100
148
- const newScale = Math.max(0.1, dragState.initScale * (1 + delta))
149
- const next = { id: dragState.id, scale: newScale }
176
+ // Resize from a corner ('resize-se' → 'se') or an edge ('resize-e' → 'e').
177
+ const handle = dragState.type.slice(7) as Corner | Edge
178
+ // dirX/dirY are DIRECTION SIGNS (+1/-1), never scales: dragging the east
179
+ // side rightwards grows the item, dragging the west side rightwards
180
+ // shrinks it. Deliberately NOT named sx/sy — those mean per-axis SCALE
181
+ // everywhere else in this hook, and conflating the two silently inverts
182
+ // the drag direction.
183
+ const dirX = handle.includes('e') ? 1 : -1
184
+ const dirY = handle.includes('s') ? 1 : -1
185
+
186
+ const baseX = dragState.initScaleX ?? dragState.initScale
187
+ const baseY = dragState.initScaleY ?? dragState.initScale
188
+ const isCorner = handle.length === 2
189
+
190
+ let nextScaleX = baseX
191
+ let nextScaleY = baseY
192
+ let nextScale = dragState.initScale
193
+ if (isCorner) {
194
+ // Both axes by the SAME delta — identical to the pre-per-axis formula,
195
+ // so a uniform item's corner drag feels exactly as it always did.
196
+ const delta = (dx * dirX + dy * dirY) / 100
197
+ nextScaleX = Math.max(0.1, baseX * (1 + delta))
198
+ nextScaleY = Math.max(0.1, baseY * (1 + delta))
199
+ nextScale = Math.max(0.1, dragState.initScale * (1 + delta))
200
+ } else if (handle === 'e' || handle === 'w') {
201
+ nextScaleX = Math.max(0.1, baseX * (1 + (dx * dirX) / 100))
202
+ } else {
203
+ nextScaleY = Math.max(0.1, baseY * (1 + (dy * dirY) / 100))
204
+ }
205
+
206
+ const next = { id: dragState.id, scale: nextScale, scaleX: nextScaleX, scaleY: nextScaleY }
150
207
  setLiveScale(next)
151
208
  liveScaleRef.current = next
152
209
  }
@@ -156,9 +213,26 @@ export function useDragOverlay(
156
213
  const lo = liveOffsetRef.current
157
214
  const ls = liveScaleRef.current
158
215
  const lr = liveRotationRef.current
159
- const changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number } = {}
216
+ const changes: OverlayChanges = {}
160
217
  if (lo) { changes.offsetX = lo.x; changes.offsetY = lo.y }
161
- if (ls) { changes.scale = ls.scale }
218
+ if (ls) {
219
+ changes.scale = ls.scale
220
+ // Commit shape is deliberately asymmetric, to keep legacy projects legacy:
221
+ // a CORNER drag of an item that has never carried scaleX/scaleY is the old
222
+ // uniform gesture and writes ONLY `scale`. Writing per-axis fields there
223
+ // would migrate every existing item to per-axis storage the first time
224
+ // anyone nudged it — a silent schema change for zero user benefit, since
225
+ // both axes would just hold the same number.
226
+ // An EDGE drag is per-axis by definition, and an item that ALREADY carries
227
+ // scaleX/scaleY is already per-axis (and would be corrupted by a
228
+ // scale-only commit, because `scaleX ?? scale` means the stale scaleX
229
+ // would keep winning). Both of those write both axes.
230
+ const handle = dragState!.type.startsWith('resize-') ? dragState!.type.slice(7) : ''
231
+ if (dragState!.initHasPerAxis || handle.length === 1) {
232
+ changes.scaleX = ls.scaleX
233
+ changes.scaleY = ls.scaleY
234
+ }
235
+ }
162
236
  if (lr) { changes.rotation = lr.rotation }
163
237
  if (Object.keys(changes).length) onOverlayChange?.(dragState!.id, changes)
164
238
  setDragState(null)