@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,625 @@
1
+ /**
2
+ * SP4 T6 — the WebCodecs engine's React seam.
3
+ *
4
+ * `useVideoPlayback` is this module's specification. It answers the same
5
+ * questions `PreviewPlayer` asks today — *is it playing, what is on track 0,
6
+ * what are the overlay tracks, toggle playback* — but every answer comes from
7
+ * `engine/`'s single tick instead of two `<video>` slots and three rAF clocks.
8
+ *
9
+ * ─────────────────────────────────────────────────────────────────────────
10
+ * WHAT THIS HOOK OWNS
11
+ * ─────────────────────────────────────────────────────────────────────────
12
+ * 1. **Engine lifecycle.** Created once per project IDENTITY, disposed on
13
+ * unmount or identity change; project EDITS go through `updateProject`
14
+ * (which is also how a `preparing` clip resolves when SSE delivers its
15
+ * proxy). Engine eligibility is NOT re-evaluated here — that is the call
16
+ * site's job (`PreviewPlayer`'s `useEngineMode`), per plan decision 2.
17
+ * 2. **The clock bridge** — see `emitTime` and the scrub effect below.
18
+ * 3. **The gesture anchors.** Both of the legacy hook's
19
+ * `resumeAudioContextFromGesture` call sites (`togglePlay` and the Space
20
+ * keydown listener) live *inside* `useVideoPlayback`, so engine mode would
21
+ * silently lose them. They are re-implemented here against the shared
22
+ * `audio-context.ts` module — the engine needs the resume MORE than the
23
+ * legacy path does, because a suspended context stalls its master clock
24
+ * and therefore stalls painting, not just audio.
25
+ * 4. **The audio lanes.** `project.audio.tracks` stay `<audio>` elements
26
+ * (plan decision 5) with the legacy element/GainNode lifecycle kept
27
+ * verbatim; only the per-tick sync arithmetic is rewritten, onto
28
+ * `timeline-core`'s `audioWindow`, and driven from the engine tick rather
29
+ * than from a React effect on `currentTime`.
30
+ *
31
+ * ─────────────────────────────────────────────────────────────────────────
32
+ * WHAT IT DELIBERATELY DOES NOT OWN
33
+ * ─────────────────────────────────────────────────────────────────────────
34
+ * - **Video-item volume, including >1.0.** The legacy hook routes each
35
+ * `<video>` slot through a GainNode to get amplification. The engine has no
36
+ * element to route: `createMasterClock` takes the item's `volume`/`muted`
37
+ * and scales the PCM at ring-enqueue time (T4), reached from
38
+ * `engine/index.ts`'s `SourceRequest` → `request.item.volume`. The TRACK's
39
+ * volume/mute ride the same path: the scheduler folds them into the request
40
+ * item (`withTrackAudio`) before the host ever sees it. Nothing to thread
41
+ * here either way; adding a second volume path would be the duplication the
42
+ * divergence registry exists to prevent.
43
+ * - **`<video>` slot mechanics.** No refs, no slot swap, no `onError` proxy
44
+ * fallback (`proxySupport.ts` gates the LEGACY player; the engine's own
45
+ * gate is `engine/eligibility.ts`, and a proxy that fails mid-session
46
+ * routes to the scheduler's `preparing` picture, not to a src downgrade).
47
+ */
48
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
49
+ import { audioWindow } from '@bycrux/timeline-core'
50
+ import {
51
+ createEngine,
52
+ track0VideoItems,
53
+ type AcquiredDemux,
54
+ type Engine,
55
+ type EngineStats,
56
+ type EngineStatus,
57
+ } from '../../engine'
58
+ import { getSharedAudioContext, latencySeconds, peekSharedAudioContext, resumeAudioContextFromGesture } from './audio-context'
59
+ import type { EditorProject as Project, VisualItem } from '../../schema'
60
+ import { enabledTrackItems } from '../timeline/timeline-model'
61
+
62
+ /**
63
+ * The legacy scrub effect's dead-zone (`Math.abs(currentTime -
64
+ * lastTimeRef.current) < 0.05`) — the mechanism, the constant and the reason
65
+ * are all inherited: below it, a difference is the playhead's own rounding
66
+ * coming back around, not a user asking to go somewhere.
67
+ */
68
+ export const SCRUB_DEAD_ZONE_S = 0.05
69
+
70
+ /**
71
+ * The legacy `syncAudioTracks` re-seek threshold, kept exactly. It was chosen
72
+ * against `<video>`-derived time; the engine's measured A/V error (±4ms) is an
73
+ * order of magnitude inside it, so it stays a *drift* correction rather than
74
+ * becoming a per-tick re-seek.
75
+ */
76
+ export const AUDIO_SYNC_THRESHOLD_S = 0.3
77
+
78
+ /**
79
+ * How many engine-emitted playhead values to remember for echo suppression.
80
+ * 120 ≈ two seconds at 60Hz — comfortably longer than any plausible React
81
+ * render lag, short enough that a value in it is always within ~2s of the
82
+ * playhead. See `isEcho`.
83
+ */
84
+ const EMIT_HISTORY = 120
85
+
86
+ const IDLE_STATUS: EngineStatus = {
87
+ transport: 'idle',
88
+ picture: 'black',
89
+ clipId: null,
90
+ seeking: false,
91
+ clock: 'fallback',
92
+ }
93
+
94
+ /**
95
+ * Slave one audio lane element to the transport rate R, pitch-corrected.
96
+ *
97
+ * The engine playhead advances R× (the J/K/L shuttle's fast-forward), so a lane
98
+ * playing at `playbackRate = R` keeps its `currentTime` in step with the picture
99
+ * — music/VO speed up together instead of drifting. `preservesPitch` stops the
100
+ * speed-up from chipmunking; the vendor-prefixed variants are set best-effort
101
+ * for engines that only understand those.
102
+ */
103
+ function applyLaneRate(el: HTMLAudioElement, rate: number): void {
104
+ el.playbackRate = rate
105
+ el.preservesPitch = true
106
+ const vendor = el as unknown as { mozPreservesPitch?: boolean; webkitPreservesPitch?: boolean }
107
+ if ('mozPreservesPitch' in vendor) vendor.mozPreservesPitch = true
108
+ if ('webkitPreservesPitch' in vendor) vendor.webkitPreservesPitch = true
109
+ }
110
+
111
+ /**
112
+ * What `PreviewPlayer` consumes, minus the `<video>`-slot keys (refs, slot
113
+ * index, `onTimeUpdate`/`onPause`/`onEnded`/`onError` handlers) — in engine
114
+ * mode those elements do not exist, and the surface renders `EngineSurface`
115
+ * instead of the two slots. Two of the survey's slot-adjacent keys are kept
116
+ * because `PreviewPlayer` reads them outside the slot JSX or would otherwise
117
+ * need a second code path; both are documented at their definition below.
118
+ */
119
+ export interface EnginePlayback {
120
+ /** `transport === 'playing'`. Drives the play-button overlay and `OverlayItemsLayer`. */
121
+ isPlaying: boolean
122
+ /**
123
+ * **Inert.** The legacy hook exposes this so the `<video>` elements' own
124
+ * `onPlay` can push the browser's opinion of playback back into React. The
125
+ * engine has no element with an opinion — the transport is authoritative and
126
+ * flows the other way — so this is a documented no-op, kept only so the
127
+ * shared surface's prop shape is one interface rather than two.
128
+ */
129
+ setIsPlaying: (value: boolean | ((prev: boolean) => boolean)) => void
130
+ /**
131
+ * The `showVideo` analog: `picture === 'video'`. The legacy hook uses it to
132
+ * zero a slot's opacity through a gap; the engine paints its own black, so
133
+ * this is informational for the surface (and for T7's HUD) rather than
134
+ * load-bearing.
135
+ */
136
+ showVideo: boolean
137
+ /** Gesture-anchored play/pause. Resumes the shared AudioContext first. */
138
+ togglePlay: () => void
139
+ /**
140
+ * The J/K/L shuttle's live transport rate. Pushes R onto the engine (project
141
+ * time advances R×, audio pitch-corrected by the engine's own time-stretch)
142
+ * AND onto every `<audio>` lane's `playbackRate`, so music/VO fast-forward in
143
+ * lockstep. Reset to 1 by the shuttle on reverse/stop.
144
+ */
145
+ setRate: (rate: number) => void
146
+ /** No track-0 video items — the legacy `isCanvasProject`, same predicate. */
147
+ isCanvasProject: boolean
148
+ /** Track-0 video items, start-sorted. Identical to the legacy `clips` memo. */
149
+ clips: VisualItem[]
150
+ /** Everything else on track 0 (images, overlays). */
151
+ tracks0NonVideo: VisualItem[]
152
+ /** `project.tracks[1..]`. */
153
+ overlayTracks: VisualItem[][]
154
+ /** Live engine status — `EngineSurface` renders the picture state from it. */
155
+ status: EngineStatus
156
+ /** Canvas ref callback. Stable identity, so React never re-attaches on a re-render. */
157
+ attachCanvas: (canvas: HTMLCanvasElement | null) => void
158
+ /**
159
+ * T7's debug HUD reads engine stats through this rather than through React
160
+ * state, and polls it on its own timer — `status` above only changes on a
161
+ * genuine transport/picture/clip/clock transition (`scheduler.ts`'s
162
+ * `publish`), so fps/buffer, which move every painted frame, would sit
163
+ * stale between those events if the HUD keyed off `status` instead. Stable
164
+ * identity, like `attachCanvas`. `null` whenever there is no live engine
165
+ * (between project identities, or after dispose).
166
+ */
167
+ getStats: () => EngineStats | null
168
+ /**
169
+ * The engine's shared demux LRU, exposed for the audible drag-scrub source
170
+ * (`../../engine/scrub-source.ts`) — it composites over this graph rather
171
+ * than driving the master clock's ring, and pins/releases through the same
172
+ * LRU the scheduler reads from (`../../engine/index.ts`'s `acquirePinnedDemux`).
173
+ * Stable identity, like `getStats`.
174
+ */
175
+ acquireDemux: (src: string) => Promise<AcquiredDemux>
176
+ }
177
+
178
+ export function useEnginePlayback(
179
+ project: Project,
180
+ currentTime: number,
181
+ onTimeUpdate: (t: number) => void,
182
+ fileUrl: (path: string) => string,
183
+ ): EnginePlayback {
184
+ // ── Derived collections (the legacy memos, verbatim) ──────────────────────
185
+ // `track0VideoItems` IS the legacy `clips` memo, lifted into the scheduler so
186
+ // one definition serves both the engine's tick and this surface.
187
+ const clips = useMemo(() => track0VideoItems(project), [project])
188
+ const tracks0NonVideo = useMemo(() => (enabledTrackItems(project)[0] ?? []).filter(c => c.type !== 'video'), [project])
189
+ const overlayTracks = useMemo(() => enabledTrackItems(project).slice(1), [project])
190
+ const isCanvasProject = clips.length === 0
191
+
192
+ const [status, setStatus] = useState<EngineStatus>(IDLE_STATUS)
193
+ const transportRef = useRef<EngineStatus['transport']>('idle')
194
+
195
+ const engineRef = useRef<Engine | null>(null)
196
+ const canvasRef = useRef<HTMLCanvasElement | null>(null)
197
+
198
+ // Latest-value refs for everything the engine's construction-time callbacks
199
+ // read. The engine captures those callbacks ONCE (see `EngineDeps.onTime`), so
200
+ // they must be stable and must not close over a render's values.
201
+ const fileUrlRef = useRef(fileUrl)
202
+ useEffect(() => { fileUrlRef.current = fileUrl }, [fileUrl])
203
+ const onTimeRef = useRef(onTimeUpdate)
204
+ useEffect(() => { onTimeRef.current = onTimeUpdate }, [onTimeUpdate])
205
+ const currentTimeRef = useRef(currentTime)
206
+
207
+ // ── The clock bridge ──────────────────────────────────────────────────────
208
+ //
209
+ // Two directions over ONE `PlaybackClock`, and the whole problem is telling
210
+ // them apart:
211
+ //
212
+ // engine → editor every tick, `emitTime` writes the playhead into
213
+ // `clock.set`. Timeline components re-render from it.
214
+ // editor → engine Scrubber/Timeline/track rows call `clock.set(t)`
215
+ // DIRECTLY (unchanged — they know nothing about either
216
+ // playback path). That arrives here as a changed
217
+ // `currentTime` prop and must become `engine.seek(t)`.
218
+ //
219
+ // `lastEmittedRef` is written BEFORE the value reaches `clock.set`, so by the
220
+ // time React re-renders with it the mirror is already in place and the scrub
221
+ // effect can recognize its own echo.
222
+ const lastEmittedRef = useRef(currentTime)
223
+ /**
224
+ * Mirrors the RAW (frames-consumed) time `emitTime` receives, alongside
225
+ * `lastEmittedRef`'s AUDIBLE (latency-compensated) mirror. `syncAudioTracks`
226
+ * callsites outside `emitTime` itself — the audio-lane-added and
227
+ * transport-transition effects — need this one: `<audio>` elements are
228
+ * frames-consumed devices on the same graph, and handing them the
229
+ * compensated value double-lags their output by `latencySeconds`.
230
+ */
231
+ const lastRawTimeRef = useRef(currentTime)
232
+ /**
233
+ * Ring of values emitted since the last external seek.
234
+ *
235
+ * The dead-zone alone satisfies "an echo of the LATEST emission never seeks".
236
+ * It does not satisfy the stronger contract — *engine-originated echoes never
237
+ * re-trigger seeks* — because React can commit a render carrying an older
238
+ * snapshot: at 60Hz, one 100ms hitch puts `currentTime` ~6 frames behind
239
+ * `lastEmittedRef`, past the 0.05s dead zone, and the bridge would answer a
240
+ * frame it emitted itself with a backwards seek (which restarts the decode
241
+ * stream, which lengthens the hitch). Every stale echo is bit-exactly a value
242
+ * this hook emitted, so exact membership is the exact test.
243
+ *
244
+ * Cleared on every external seek — never on a transport change, because
245
+ * `pause()` publishes a status without ticking and a stale echo arriving just
246
+ * after it would then read as a scrub and rewind the playhead. Values in the
247
+ * ring are therefore always playback positions from the last ≤2s of the
248
+ * CURRENT seek segment, which a scrub target cannot plausibly hit bit-exactly
249
+ * (and if it did, it would be a scrub to where the playhead already is).
250
+ */
251
+ const emittedRef = useRef<number[]>([])
252
+
253
+ const rememberEmitted = (t: number) => {
254
+ const ring = emittedRef.current
255
+ ring.push(t)
256
+ if (ring.length > EMIT_HISTORY) ring.splice(0, ring.length - EMIT_HISTORY)
257
+ }
258
+ const isEcho = (t: number) => emittedRef.current.includes(t)
259
+
260
+ /**
261
+ * The transport as of RIGHT NOW, not as of the last published status.
262
+ *
263
+ * The engine emits the playhead and then publishes the status, so inside a
264
+ * tick `transportRef` is one step behind — and the two ticks where that
265
+ * matters are exactly the ones the audio lanes care about: the tick that
266
+ * starts playback (lanes must start with it) and the tick a looping clip
267
+ * stops on (lanes must stop with it). `transportRef` remains the fallback for
268
+ * the window between construction and the first status.
269
+ */
270
+ const isEnginePlaying = () =>
271
+ (engineRef.current?.status().transport ?? transportRef.current) === 'playing'
272
+
273
+ // ── Audio lanes ───────────────────────────────────────────────────────────
274
+ // Element/GainNode lifecycle lifted from `useVideoPlayback` unchanged (same
275
+ // identity key, same "volume never churns elements" rule, same
276
+ // never-close-the-context cleanup). Only `syncAudioTracks` is rewritten.
277
+ const audioRefsMap = useRef<Map<string, HTMLAudioElement>>(new Map())
278
+ const audioSrcMap = useRef<Map<string, string>>(new Map())
279
+ const gainNodesMap = useRef<Map<string, GainNode>>(new Map())
280
+ // The live transport rate R (the shuttle's fast-forward). Read every time a
281
+ // lane is (re)synced so a lane that starts or resumes mid-shuttle inherits it.
282
+ const transportRateRef = useRef(1)
283
+
284
+ const unmutedAudioTracks = useMemo(
285
+ () => (project.audio?.tracks ?? []).filter(t => !t.muted && t.src),
286
+ [project.audio?.tracks],
287
+ )
288
+ const audioTrackIdentity = useMemo(
289
+ () => unmutedAudioTracks.map(t => `${t.id}:${t.src}`).join('|'),
290
+ [unmutedAudioTracks],
291
+ )
292
+ const unmutedAudioTracksRef = useRef(unmutedAudioTracks)
293
+ useEffect(() => { unmutedAudioTracksRef.current = unmutedAudioTracks }, [unmutedAudioTracks])
294
+
295
+ /**
296
+ * Slave every audio lane to the engine's playhead.
297
+ *
298
+ * The legacy version computed the window and the fade envelope inline; this
299
+ * one asks `timeline-core`'s `audioWindow` — the pure port of exactly that
300
+ * arithmetic, including the derived-outPoint rule (the stored `outPoint` can
301
+ * drift out of sync with start/end during a trim and cause premature
302
+ * silence). Finishing that adoption is plan decision 5.
303
+ *
304
+ * Stable identity (`[]` + refs), because the engine's tick callback captures
305
+ * it once at construction.
306
+ */
307
+ const syncAudioTracks = useCallback(function syncAudioTracks(playhead: number, playing: boolean) {
308
+ for (const track of unmutedAudioTracksRef.current) {
309
+ const el = audioRefsMap.current.get(track.id)
310
+ if (!el) continue
311
+
312
+ const win = audioWindow(track, playhead)
313
+ if (!win.active) {
314
+ if (!el.paused) el.pause()
315
+ continue
316
+ }
317
+
318
+ // Speed the lane up with the picture (pitch-corrected). The playhead and
319
+ // the lane both advance R× real time, so `win.trackTime` and the element's
320
+ // own `currentTime` stay in step and the drift re-seek below keeps working
321
+ // in project-time terms — no need to scale its tolerance.
322
+ applyLaneRate(el, transportRateRef.current)
323
+
324
+ if (Math.abs(el.currentTime - win.trackTime) > AUDIO_SYNC_THRESHOLD_S) {
325
+ el.currentTime = Math.max(0, win.trackTime)
326
+ }
327
+ if (playing && el.paused) el.play().catch(() => {})
328
+ if (!playing && !el.paused) el.pause()
329
+
330
+ // `audioWindow.gain` is already `baseVolume * max(0, fadeMul)`.
331
+ const gain = gainNodesMap.current.get(track.id)
332
+ if (gain) gain.gain.value = win.gain
333
+ }
334
+ }, [])
335
+
336
+ // Create / destroy elements when the track SET changes (not on volume drags).
337
+ useEffect(() => {
338
+ const map = audioRefsMap.current
339
+ const srcMap = audioSrcMap.current
340
+ const gains = gainNodesMap.current
341
+ const activeIds = new Set(unmutedAudioTracks.map(t => t.id))
342
+
343
+ for (const [id, el] of map) {
344
+ if (!activeIds.has(id)) {
345
+ el.pause()
346
+ el.src = ''
347
+ map.delete(id)
348
+ srcMap.delete(id)
349
+ gains.delete(id)
350
+ }
351
+ }
352
+
353
+ for (const track of unmutedAudioTracks) {
354
+ let el = map.get(track.id)
355
+ if (!el) {
356
+ el = new Audio()
357
+ el.preload = 'auto'
358
+ map.set(track.id, el)
359
+ // element → GainNode → destination, on the SHARED context (volume > 1.0).
360
+ // createMediaElementSource can only be called once per element.
361
+ const ctx = getSharedAudioContext()
362
+ const source = ctx.createMediaElementSource(el)
363
+ const gain = ctx.createGain()
364
+ gain.gain.value = track.volume ?? 1
365
+ source.connect(gain)
366
+ gain.connect(ctx.destination)
367
+ gains.set(track.id, gain)
368
+ }
369
+ if (srcMap.get(track.id) !== track.src) {
370
+ el.src = fileUrlRef.current(track.src!)
371
+ srcMap.set(track.id, track.src!)
372
+ }
373
+ const gain = gains.get(track.id)
374
+ if (gain) gain.gain.value = track.volume ?? 1
375
+ }
376
+
377
+ // A lane added mid-session has to be placed at the current playhead
378
+ // immediately; the next engine tick would otherwise be the first thing to
379
+ // touch it, and while paused there is no next tick.
380
+ syncAudioTracks(lastRawTimeRef.current, isEnginePlaying())
381
+ // Keyed on the identity string ALONE, exactly as the legacy hook keys it: the
382
+ // effect must fire on adds/removes/src changes and never on a volume drag
383
+ // (which would tear down and rebuild every element mid-playback). The closure
384
+ // over `unmutedAudioTracks` is from the render in which the identity last
385
+ // changed, which is the render whose track set this effect is reconciling.
386
+ }, [audioTrackIdentity])
387
+
388
+ // Volume in place, no element churn.
389
+ useEffect(() => {
390
+ for (const track of unmutedAudioTracks) {
391
+ const gain = gainNodesMap.current.get(track.id)
392
+ if (gain) gain.gain.value = track.volume ?? 1
393
+ }
394
+ }, [unmutedAudioTracks])
395
+
396
+ // Unmount only. The shared AudioContext is window-scoped and never closed.
397
+ useEffect(() => {
398
+ const map = audioRefsMap.current
399
+ const srcMap = audioSrcMap.current
400
+ const gains = gainNodesMap.current
401
+ return () => {
402
+ for (const el of map.values()) { el.pause(); el.src = '' }
403
+ map.clear()
404
+ srcMap.clear()
405
+ gains.clear()
406
+ }
407
+ }, [])
408
+
409
+ // ── Engine lifecycle ──────────────────────────────────────────────────────
410
+
411
+ /** Mirror-then-forward. The ORDER is the bridge's whole contract. */
412
+ const emitTime = useCallback((t: number) => {
413
+ // `t` is frames-CONSUMED time (`samplesConsumed / sampleRate + anchor`);
414
+ // those frames become audible `latencySeconds` later. Painting to `t`
415
+ // while playing puts the picture ahead of the ear by that gap ("laggy
416
+ // audio") — subtract live off the shared context so a device switch
417
+ // (which changes `outputLatency`) is picked up on the very next tick.
418
+ //
419
+ // Skipped when NOT playing: the paused path's onTime fires from
420
+ // `scheduler.apply` on seek-land with the exact seek target, and
421
+ // compensating that would offset the scrubbed-to display.
422
+ //
423
+ // The store, `rememberEmitted` and `lastEmittedRef` all mirror the
424
+ // AUDIBLE value so the echo coming back through `currentTime` compares
425
+ // cleanly. `syncAudioTracks` gets the RAW `t`, though: `<audio>` elements
426
+ // are frames-consumed devices on the same graph, and syncing them to the
427
+ // audible value would double-lag their output by `latencySeconds`.
428
+ const playing = isEnginePlaying()
429
+ // `peek` never creates: production reaches the playing branch only after
430
+ // `togglePlay`'s gesture, which has already minted the shared ctx via
431
+ // `getSharedAudioContext()`. No ctx means no gesture yet, and the
432
+ // frames-consumed clock has nothing audible behind it to lag.
433
+ const ctx = playing ? peekSharedAudioContext() : undefined
434
+ const painted = ctx ? Math.max(0, t - latencySeconds(ctx)) : t
435
+ lastEmittedRef.current = painted
436
+ lastRawTimeRef.current = t
437
+ rememberEmitted(painted)
438
+ onTimeRef.current(painted)
439
+ syncAudioTracks(t, playing)
440
+ }, [syncAudioTracks])
441
+
442
+ const handleStatus = useCallback((next: EngineStatus) => {
443
+ // Written synchronously, ahead of the React state, because `emitTime` runs
444
+ // inside the same tick and needs the transport that is true NOW.
445
+ transportRef.current = next.transport
446
+ setStatus(next)
447
+ }, [])
448
+
449
+ const handleError = useCallback((message: string) => {
450
+ console.warn(`[montaj] ${message}`)
451
+ }, [])
452
+
453
+ // One engine per project IDENTITY. Edits go through `updateProject` below;
454
+ // a different project id is a teardown. The engine is built from a ref rather
455
+ // than the closed-over `project` so that an edit landing in the same commit
456
+ // as the identity change still builds from the newest timeline.
457
+ const projectId = project.id
458
+ const projectRef = useRef(project)
459
+ projectRef.current = project
460
+ /** The project object the engine has already been told about. */
461
+ const appliedProjectRef = useRef<Project | null>(null)
462
+
463
+ useEffect(() => {
464
+ const engine = createEngine(projectRef.current, {
465
+ fileUrl: (path: string) => fileUrlRef.current(path),
466
+ onTime: emitTime,
467
+ onStatusChange: handleStatus,
468
+ onError: handleError,
469
+ startProjectS: currentTimeRef.current,
470
+ })
471
+ engineRef.current = engine
472
+ lastEmittedRef.current = currentTimeRef.current
473
+ lastRawTimeRef.current = currentTimeRef.current
474
+ emittedRef.current = []
475
+ // The project object the engine was just built with is, by definition,
476
+ // already applied — without this the edit effect below would re-apply it.
477
+ appliedProjectRef.current = projectRef.current
478
+ // The canvas ref callback fires during commit, BEFORE this effect, so on a
479
+ // first mount the canvas is already here and waiting to be bound.
480
+ if (canvasRef.current) engine.attach(canvasRef.current)
481
+ return () => {
482
+ engineRef.current = null
483
+ transportRef.current = 'idle'
484
+ engine.dispose()
485
+ }
486
+ }, [projectId, emitTime, handleStatus, handleError])
487
+
488
+ // Project EDITS. `updateProject` re-runs the tick against the new timeline —
489
+ // and is the path a `preparing` clip resolves through when SSE delivers its
490
+ // proxySrc. Skipped for the object the engine was just built with.
491
+ useEffect(() => {
492
+ const engine = engineRef.current
493
+ if (!engine) return
494
+ if (appliedProjectRef.current === project) return
495
+ appliedProjectRef.current = project
496
+ engine.updateProject(project)
497
+ }, [project])
498
+
499
+ // ── The scrub half of the bridge ──────────────────────────────────────────
500
+ useEffect(() => {
501
+ currentTimeRef.current = currentTime
502
+ const engine = engineRef.current
503
+ if (!engine) {
504
+ // No engine yet (first render, or between project identities): the store
505
+ // is the only truth, so adopt it as the mirror rather than seeking a
506
+ // later engine to a stale value.
507
+ lastEmittedRef.current = currentTime
508
+ lastRawTimeRef.current = currentTime
509
+ return
510
+ }
511
+ if (isEcho(currentTime)) return
512
+ if (Math.abs(currentTime - lastEmittedRef.current) < SCRUB_DEAD_ZONE_S) return
513
+ // External scrub. Mirror FIRST so a re-render before the engine's own echo
514
+ // lands cannot fire a second seek for the same gesture.
515
+ lastEmittedRef.current = currentTime
516
+ lastRawTimeRef.current = currentTime
517
+ emittedRef.current = []
518
+ engine.seek(currentTime)
519
+ }, [currentTime])
520
+
521
+ // Transport transitions the tick cannot report: `pause()` publishes a status
522
+ // but never runs a tick, so the lanes would keep playing without this.
523
+ useEffect(() => {
524
+ syncAudioTracks(lastRawTimeRef.current, status.transport === 'playing')
525
+ }, [status.transport, syncAudioTracks])
526
+
527
+ // ── Gestures ──────────────────────────────────────────────────────────────
528
+
529
+ const togglePlay = useCallback(() => {
530
+ // GESTURE-ANCHORED, and more load-bearing here than on the legacy path: the
531
+ // engine's master clock counts frames an AudioWorklet has rendered, and a
532
+ // suspended context renders none — so a suspended context freezes the
533
+ // PICTURE, not just the sound. Must be called synchronously inside the
534
+ // gesture for the browser to credit it.
535
+ //
536
+ // `getSharedAudioContext()` FIRST: `resumeAudioContextFromGesture`
537
+ // deliberately never creates the context (see its own doc), so on the
538
+ // very first play — nothing has reached line 307's lane-creation path yet
539
+ // for a project with no audio tracks, and a video-only clip's context is
540
+ // built inside `createMasterClock`, off the gesture stack entirely — the
541
+ // context would not exist yet and there would be nothing to resume. This
542
+ // creates it synchronously inside the gesture, so the browser credits the
543
+ // creation (and the resume that follows) as gesture-driven.
544
+ getSharedAudioContext()
545
+ resumeAudioContextFromGesture()
546
+ const engine = engineRef.current
547
+ if (!engine) return
548
+ if (engine.status().transport !== 'playing') {
549
+ // `play()` runs a tick, which emits the playhead for free.
550
+ engine.play()
551
+ return
552
+ }
553
+ engine.pause()
554
+ // `pause()` publishes a status but never ticks, so nothing else would push
555
+ // the exact stop position into the editor's clock — the store would keep
556
+ // whatever the last rendered frame's value was.
557
+ emitTime(engine.clock.now())
558
+ }, [emitTime])
559
+
560
+ // The J/K/L shuttle's rate knob. Push R onto the engine transport (project
561
+ // time advances R×, audio pitch-corrected by the engine's streaming
562
+ // time-stretch) and onto every audio lane element (`<audio>` slaved to the
563
+ // playhead) so music/VO fast-forward in lockstep rather than drifting. Stored
564
+ // in a ref so a lane started or re-synced later inherits the current rate
565
+ // (see `syncAudioTracks` and the lane-creation effect).
566
+ const setRate = useCallback((rate: number) => {
567
+ transportRateRef.current = rate
568
+ engineRef.current?.setRate(rate)
569
+ for (const el of audioRefsMap.current.values()) applyLaneRate(el, rate)
570
+ }, [])
571
+
572
+ // Space = play/pause. The legacy hook's keydown block is the spec: same
573
+ // typing-surface guard (an `<input>`, a `<textarea>` or any contentEditable
574
+ // host — which is what the caption/overlay editors and every modal field
575
+ // are), same `e.code === 'Space'` test, same `preventDefault()` (otherwise
576
+ // Space scrolls the editor), same gesture-anchored resume. What it does NOT
577
+ // reproduce is the canvas-project and in-gap branching: those existed because
578
+ // three different clocks owned playback in three different situations, and
579
+ // the engine has one transport for all of them.
580
+ useEffect(() => {
581
+ const onKeyDown = (e: KeyboardEvent) => {
582
+ const el = e.target as HTMLElement
583
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable) return
584
+ if (e.code !== 'Space') return
585
+ e.preventDefault()
586
+ togglePlay()
587
+ }
588
+ document.addEventListener('keydown', onKeyDown)
589
+ return () => document.removeEventListener('keydown', onKeyDown)
590
+ }, [togglePlay])
591
+
592
+ // ── Canvas binding ────────────────────────────────────────────────────────
593
+ const attachCanvas = useCallback((canvas: HTMLCanvasElement | null) => {
594
+ canvasRef.current = canvas
595
+ engineRef.current?.attach(canvas)
596
+ }, [])
597
+
598
+ const setIsPlaying = useCallback(() => {
599
+ /* inert — see the interface note */
600
+ }, [])
601
+
602
+ const getStats = useCallback((): EngineStats | null => engineRef.current?.stats() ?? null, [])
603
+
604
+ const acquireDemux = useCallback((src: string): Promise<AcquiredDemux> => {
605
+ const engine = engineRef.current
606
+ if (!engine) return Promise.reject(new Error('scrub-source: engine not attached'))
607
+ return engine.acquireDemux(src)
608
+ }, [])
609
+
610
+ return {
611
+ isPlaying: status.transport === 'playing',
612
+ setIsPlaying,
613
+ showVideo: status.picture === 'video',
614
+ togglePlay,
615
+ setRate,
616
+ isCanvasProject,
617
+ clips,
618
+ tracks0NonVideo,
619
+ overlayTracks,
620
+ status,
621
+ attachCanvas,
622
+ getStats,
623
+ acquireDemux,
624
+ }
625
+ }