@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,4 +1,5 @@
1
- import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react'
1
+ import { useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react'
2
+ import { containsTime } from '@bycrux/timeline-core'
2
3
  import { videoTransformContainerStyle, videoTransformBoxPct, type VideoTransform } from './transformStyle'
3
4
  import type { EditorProject as Project } from '../../schema'
4
5
  import type { OverlayFactory } from '../../types'
@@ -9,12 +10,49 @@ import type { OverlayChanges } from './useDragOverlay'
9
10
  import type { CaptionEditPatch } from '../timeline/makeCaptionEdit'
10
11
  import OverlayItemsLayer from './OverlayItemsLayer'
11
12
  import { useVideoPlayback } from './useVideoPlayback'
13
+ import { useEnginePlayback, type EnginePlayback } from './useEnginePlayback'
14
+ import EngineSurface from './EngineSurface'
15
+ import { evaluateEngineEligibility } from '../../engine/eligibility'
16
+ import type { AcquiredDemux } from '../../engine'
12
17
  import { usePlaybackTime, type PlaybackClock } from '../playback-clock'
18
+ import { gateTimeSink, handOverToHover, useHoverScrubTime, type HoverScrub } from '../hover-scrub'
13
19
  import { sourceCropVideoStyle } from './sourceCropStyle'
14
20
  import CarouselPreview from './CarouselPreview'
21
+ import SocialSafeZoneOverlay, { type SocialPreviewPlatform } from './SocialSafeZoneOverlay'
15
22
 
16
23
  // ---------------------------------------------------------------------------
17
24
 
25
+ /**
26
+ * SP5 T9 — the transport seam. The ONE cross-component API a host-level
27
+ * keymap needs to drive play/pause without knowing which playback path
28
+ * (legacy `<video>` slots or the WebCodecs engine) is actually running.
29
+ * `PreviewPlayer` fills it from whichever hook is active; see
30
+ * `PreviewSurface` below.
31
+ */
32
+ export interface TransportHandle {
33
+ togglePlay: () => void
34
+ isPlaying: () => boolean
35
+ /** The J/K/L shuttle's live transport rate. Engine mode drives the engine's
36
+ * own rate (audible fast-forward); the legacy path has no rate knob, so it is
37
+ * a no-op there. */
38
+ setRate: (rate: number) => void
39
+ }
40
+
41
+ /**
42
+ * The audible drag-scrub source's (`../../engine/scrub-source.ts`) seam onto
43
+ * the engine's shared demux LRU — mirrors `TransportHandle`'s shape. Filled
44
+ * only on the WebCodecs engine path (see the effect beside `transportRef`'s,
45
+ * below); stays null on the legacy `<video>` fallback, since a fallback
46
+ * project's clips either lack a `proxySrc` or can't be engine-decoded — this
47
+ * absence IS the capability gate the scrubber needs.
48
+ */
49
+ export interface ScrubHandle {
50
+ acquireDemux: (src: string) => Promise<AcquiredDemux>
51
+ }
52
+
53
+ /** Stable no-op for the legacy playback path, which has no transport-rate knob. */
54
+ const NO_RATE = (_rate: number) => {}
55
+
18
56
  interface PreviewPlayerProps {
19
57
  project: Project
20
58
  clock: PlaybackClock
@@ -33,11 +71,195 @@ interface PreviewPlayerProps {
33
71
  selectedCaptionId?: string
34
72
  onSelectCaption?: (id: string | null) => void
35
73
  onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
74
+ /**
75
+ * SP4 — mirrors `VideoEditorProps['engine']` (see `../../types.ts`).
76
+ * Absent/`{enabled: false}` (default): no effect, the legacy `<video>` slots
77
+ * render exactly as before — the eligibility probe is never even run.
78
+ * `{enabled: true}` asks for the WebCodecs engine; whether this project
79
+ * actually gets it is decided by `useEngineMode` below.
80
+ */
81
+ engine?: { enabled: boolean; debugHud?: boolean }
82
+ /**
83
+ * SP5 T9 — imperative play/pause handle for a host-level keymap/command
84
+ * palette. Filled from whichever playback hook is active (legacy or
85
+ * engine). Absent → no-op; nothing reads or writes it.
86
+ */
87
+ transportRef?: MutableRefObject<TransportHandle | null>
88
+ /**
89
+ * The audible drag-scrub source's engine seam — mirrors `transportRef`.
90
+ * Absent → no-op; nothing reads or writes it.
91
+ */
92
+ scrubHandleRef?: MutableRefObject<ScrubHandle | null>
93
+ /**
94
+ * The preview-axis override: while the pointer is over the timeline with the
95
+ * axis on, this holds the time under it and the preview paints THAT frame
96
+ * instead of the playhead's. Null (or the store absent) → the playhead's
97
+ * frame, which is every other moment and every other host.
98
+ *
99
+ * Deliberately not folded into `clock`: the playhead must not follow the
100
+ * pointer, or the red line and the scrubber handle would chase the mouse and
101
+ * Space would start playing from wherever it happened to rest. See
102
+ * `../hover-scrub.ts`.
103
+ */
104
+ hoverScrub?: HoverScrub
105
+ /**
106
+ * Social-platform preview chrome (mirrors CapCut's "Preview your video for
107
+ * social media" picker) — see `SocialSafeZoneOverlay`. Absent/falsy/
108
+ * unrecognized → no-op, same contract as that component's own `platform`
109
+ * prop, which this passes straight through. Threaded in here (rather than
110
+ * mounted by the host as a sibling of `PreviewPlayer`) so it lands INSIDE
111
+ * the same stacking context as the rest of the picture — see the z-index
112
+ * note at its render site below.
113
+ */
114
+ socialPreview?: SocialPreviewPlatform | string | null
115
+ }
116
+
117
+ export default function PreviewPlayer(props: PreviewPlayerProps) {
118
+ if (props.project.projectType === 'carousel') {
119
+ // CarouselPreview renders into normal document flow (no `isolation:
120
+ // isolate` stacking context of its own — see VideoPreviewPlayer/
121
+ // PreviewSurface below), so the social-preview overlay composes correctly
122
+ // as a plain sibling here; it only needs to move INSIDE the video path.
123
+ return (
124
+ <>
125
+ <CarouselPreview project={props.project} />
126
+ <SocialSafeZoneOverlay platform={props.socialPreview} />
127
+ </>
128
+ )
129
+ }
130
+ return <VideoPreviewPlayer {...props} />
131
+ }
132
+
133
+ // ── Which playback path owns this project ────────────────────────────────────
134
+
135
+ type EngineMode = 'legacy' | 'engine'
136
+
137
+ /**
138
+ * Plan decision 2, at its call site.
139
+ *
140
+ * Eligibility is evaluated ONCE per project LOAD — re-run only when the project
141
+ * IDENTITY changes (a different `project.id`), never on an edit. Two
142
+ * consequences the plan states explicitly and this hook is the enforcement of:
143
+ *
144
+ * - a project that was eligible stays on the engine for the whole session,
145
+ * even if a clip added later has no proxy yet (that clip alone shows
146
+ * `EngineSurface`'s Preparing placeholder — the engine never hands a
147
+ * running project back to the legacy player);
148
+ * - a project that was INELIGIBLE stays on the legacy player for the whole
149
+ * session, even if its proxies finish encoding a minute later. A reload
150
+ * picks up engine mode. The alternative — swapping playback engines under a
151
+ * playing editor — is the mode-flapping the decision exists to forbid.
152
+ *
153
+ * Returns `null` while the (async) capability probe is still outstanding, which
154
+ * can only happen with the flag ON: with it off the answer is `'legacy'`
155
+ * synchronously, on the first render, with no probe and no console line.
156
+ */
157
+ function useEngineMode(
158
+ engine: PreviewPlayerProps['engine'],
159
+ project: Project,
160
+ ): EngineMode | null {
161
+ const enabled = !!engine?.enabled
162
+ const projectId = project.id
163
+ const [decision, setDecision] = useState<{ id: string; mode: EngineMode } | null>(null)
164
+
165
+ useEffect(() => {
166
+ if (!enabled) return
167
+ let cancelled = false
168
+ void evaluateEngineEligibility(project).then((result) => {
169
+ if (cancelled) return
170
+ if (!result.eligible) {
171
+ // ONE line, with the reason. Not a warning: falling back is the
172
+ // designed behavior, not a fault.
173
+ console.info(
174
+ `[montaj] playback engine unavailable for this project — using the legacy player (${result.reason ?? 'ineligible'})`,
175
+ )
176
+ }
177
+ setDecision({ id: projectId, mode: result.eligible ? 'engine' : 'legacy' })
178
+ })
179
+ return () => { cancelled = true }
180
+ // Deliberately NOT `[project]`: an edit must not re-evaluate. The `project`
181
+ // captured here is the one loaded under this id, which is what "evaluated
182
+ // once per project-load" means.
183
+ }, [enabled, projectId])
184
+
185
+ if (!enabled) return 'legacy'
186
+ if (!decision || decision.id !== projectId) return null
187
+ return decision.mode
188
+ }
189
+
190
+ function VideoPreviewPlayer(props: PreviewPlayerProps) {
191
+ const { project, clock, engine } = props
192
+ // Subscribe to the playhead store. PreviewPlayer legitimately re-renders per
193
+ // tick — activeClip/cropStyle memos and the video/overlay/caption children all
194
+ // depend on the current time.
195
+ const playheadTime = usePlaybackTime(clock)
196
+ // Hover-scrub wins while it is set. Everything downstream — the playback
197
+ // hooks' seeking, the active-clip memo, the overlay and caption layers —
198
+ // reads this one number, so the previewed frame, the overlays drawn on it and
199
+ // the highlighted caption all describe the same instant.
200
+ const hoverTime = useHoverScrubTime(props.hoverScrub)
201
+ const currentTime = hoverTime ?? playheadTime
202
+ const mode = useEngineMode(engine, project)
203
+
204
+ // See `gateTimeSink` — never hand the hooks `clock.set` directly, or seeking
205
+ // to show a hovered frame writes that position back and the red playhead
206
+ // chases the yellow cursor.
207
+ const hoverScrub = props.hoverScrub
208
+ const timeSink = useMemo(() => gateTimeSink(clock, hoverScrub), [clock, hoverScrub])
209
+
210
+ const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
211
+
212
+ // Flag on, probe outstanding. Holding the frame for the (sub-frame, cached
213
+ // after the first project) probe is what keeps the legacy hook from mounting,
214
+ // loading a <video>, wiring Web Audio and tearing it all down again one tick
215
+ // later. The two playback hooks are DIFFERENT components on purpose: mounting
216
+ // one and only one of them is what makes a single hook call per path legal.
217
+ if (mode === null) {
218
+ return (
219
+ <div
220
+ className="relative bg-black h-full max-w-full overflow-hidden rounded"
221
+ style={{ aspectRatio: `${RENDER_W} / ${RENDER_H}`, isolation: 'isolate' }}
222
+ />
223
+ )
224
+ }
225
+
226
+ return mode === 'engine'
227
+ ? <EnginePreview {...props} currentTime={currentTime} timeSink={timeSink} />
228
+ : <LegacyPreview {...props} currentTime={currentTime} timeSink={timeSink} />
36
229
  }
37
230
 
38
- export default function PreviewPlayer({
231
+ /**
232
+ * The two playback paths, discriminated. `PreviewSurface` reads the shared keys
233
+ * off the union directly and narrows on `mode` for the surface itself; nothing
234
+ * else in the component tree knows which path is running.
235
+ */
236
+ type PlaybackBinding =
237
+ | ({ mode: 'legacy' } & ReturnType<typeof useVideoPlayback>)
238
+ | ({ mode: 'engine' } & EnginePlayback)
239
+
240
+ type SurfaceProps = PreviewPlayerProps & {
241
+ currentTime: number
242
+ /** `clock.set`, gated on hover-preview — see `timeSink` above. Never pass
243
+ * `clock.set` directly here, or hover-seeking writes back into the clock. */
244
+ timeSink: (t: number) => void
245
+ }
246
+
247
+ function LegacyPreview(props: SurfaceProps) {
248
+ const playback = useVideoPlayback(props.project, props.currentTime, props.timeSink, props.fileUrl)
249
+ return <PreviewSurface {...props} playback={{ mode: 'legacy', ...playback }} />
250
+ }
251
+
252
+ function EnginePreview(props: SurfaceProps) {
253
+ const playback = useEnginePlayback(props.project, props.currentTime, props.timeSink, props.fileUrl)
254
+ return <PreviewSurface {...props} playback={{ mode: 'engine', ...playback }} />
255
+ }
256
+
257
+ function PreviewSurface({
39
258
  project,
40
259
  clock,
260
+ hoverScrub,
261
+ currentTime,
262
+ playback,
41
263
  selectedOverlayId,
42
264
  onOverlayChange,
43
265
  onEditOverlay,
@@ -49,16 +271,19 @@ export default function PreviewPlayer({
49
271
  selectedCaptionId,
50
272
  onSelectCaption,
51
273
  onCaptionSegmentChange,
52
- }: PreviewPlayerProps) {
53
- if (project.projectType === 'carousel') return <CarouselPreview project={project} />
54
-
55
- // Subscribe to the playhead store. PreviewPlayer legitimately re-renders per
56
- // tick — activeClip/cropStyle memos and the video/overlay/caption children all
57
- // depend on the current time.
58
- const currentTime = usePlaybackTime(clock)
59
-
274
+ engine,
275
+ transportRef,
276
+ scrubHandleRef,
277
+ socialPreview,
278
+ }: SurfaceProps & { playback: PlaybackBinding }) {
60
279
  const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
61
280
 
281
+ // Play from the yellow line, and bring the red one with it — see
282
+ // `handOverToHover`. No-op on every ordinary press of play.
283
+ useEffect(() => {
284
+ if (playback.isPlaying) handOverToHover(clock, hoverScrub)
285
+ }, [playback.isPlaying, hoverScrub, clock])
286
+
62
287
  const containerRef = useRef<HTMLDivElement>(null)
63
288
  const [renderScale, setRenderScale] = useState<number>(1)
64
289
  // Frame pixel size — used to compute the sourceCrop CSS transform that mirrors
@@ -88,22 +313,39 @@ export default function PreviewPlayer({
88
313
  } = useDragOverlay(containerRef, onOverlayChange)
89
314
 
90
315
  const {
91
- video0Ref,
92
- video1Ref,
93
- activeSlotRef,
94
- activeSlot,
95
316
  showVideo,
96
317
  isPlaying,
97
- setIsPlaying,
98
- handleTimeUpdate,
99
- handlePause,
100
- handleEnded,
101
318
  togglePlay,
102
319
  isCanvasProject,
103
320
  clips,
104
321
  tracks0NonVideo,
105
322
  overlayTracks,
106
- } = useVideoPlayback(project, currentTime, clock.set, fileUrl)
323
+ } = playback
324
+
325
+ // The transport seam (SP5 T9): fill the host's ref with the ACTIVE path's
326
+ // togglePlay/isPlaying, identically shaped for both legacy and engine
327
+ // playback — a host-level keymap never needs to know which one is running.
328
+ // `isPlaying` is exposed as a getter (not the boolean itself) so a poller
329
+ // (the J/K/L shuttle) always reads the current value, not a stale closure
330
+ // from the render that last wrote the ref.
331
+ // Engine mode carries the real rate knob; the legacy path has none (no-op).
332
+ const setRate = playback.mode === 'engine' ? playback.setRate : NO_RATE
333
+ useEffect(() => {
334
+ if (!transportRef) return
335
+ transportRef.current = { togglePlay, isPlaying: () => isPlaying, setRate }
336
+ return () => { transportRef.current = null }
337
+ }, [transportRef, togglePlay, isPlaying, setRate])
338
+
339
+ // The scrub seam: only the engine path can decode a grain, so the legacy
340
+ // `<video>` fallback simply never fills this — that absence is the audible
341
+ // drag-scrub's capability gate. `acquireDemux` is a stable-identity callback
342
+ // (see `useEnginePlayback`), so this effect only re-runs on a mode change.
343
+ const acquireDemux = playback.mode === 'engine' ? playback.acquireDemux : undefined
344
+ useEffect(() => {
345
+ if (!scrubHandleRef || !acquireDemux) return
346
+ scrubHandleRef.current = { acquireDemux }
347
+ return () => { scrubHandleRef.current = null }
348
+ }, [scrubHandleRef, acquireDemux])
107
349
 
108
350
  const captionTrack = useMemo(() => project.captions, [project])
109
351
 
@@ -113,8 +355,19 @@ export default function PreviewPlayer({
113
355
  // playhead (same selection the playback hook uses internally). Only the active
114
356
  // <video> slot is opaque, so applying the active clip's crop to both slots is
115
357
  // safe — the inactive slot is invisible.
358
+ //
359
+ // Activation is `containsTime` from @bycrux/timeline-core — the shared
360
+ // half-open `start <= t < end` predicate, so at an exact cut the LATER clip
361
+ // wins here exactly as it does in the renderer.
362
+ //
363
+ // The `?? clips[clips.length - 1]` tail is deliberately NOT part of the
364
+ // resolver: `resolveAt` returns an EMPTY Scene inside a gap or past the end of
365
+ // the timeline. Falling back to the last clip is editor-side PRESENTATION —
366
+ // it keeps the trailing frame's crop/transform on screen instead of snapping
367
+ // to an untransformed frame — so it stays local. See the `containsTime` note
368
+ // in timeline-core/index.d.ts.
116
369
  const activeClip = useMemo(
117
- () => clips.find(c => currentTime >= c.start && currentTime < c.end) ?? clips[clips.length - 1],
370
+ () => clips.find(c => containsTime(c.start, c.end, currentTime)) ?? clips[clips.length - 1],
118
371
  [clips, currentTime],
119
372
  )
120
373
  const cropStyle = useMemo(() => {
@@ -211,42 +464,59 @@ export default function PreviewPlayer({
211
464
  No clips
212
465
  </div>
213
466
  ) : (
214
- // Transform container — applies the active clip's scale/offset to both
215
- // slots (the inactive one is invisible). The frame's overflow-hidden clips
216
- // anything pushed outside. Mirrors the renderer's crop→scale→position.
467
+ // Transform container — applies the active clip's scale/offset to the
468
+ // picture surface. On the legacy path that surface is the two <video>
469
+ // slots (the inactive one is invisible); on the engine path it is one
470
+ // canvas. The div itself, its styles and its z-order are IDENTICAL for
471
+ // both — the frame's overflow-hidden clips anything pushed outside, and
472
+ // the whole thing mirrors the renderer's crop→scale→position.
217
473
  <div className="absolute inset-0" style={transformContainerStyle}>
218
- {/* Slot 0 */}
219
- <video
220
- ref={video0Ref}
221
- // Clips load cross-origin from R2; without this the media is CORS-tainted
222
- // and the Web Audio createMediaElementSource graph outputs silence. R2
223
- // sends Access-Control-Allow-Origin, so anonymous CORS keeps it audible.
224
- crossOrigin="anonymous"
225
- // Fetch enough to render the seeked poster frame on load (before play).
226
- preload="auto"
227
- onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
228
- onTimeUpdate={() => { if (activeSlotRef.current === 0) handleTimeUpdate() }}
229
- onEnded={() => { if (activeSlotRef.current === 0) handleEnded() }}
230
- onPlay={() => { if (activeSlotRef.current === 0) setIsPlaying(true) }}
231
- onPause={() => { if (activeSlotRef.current === 0) handlePause() }}
232
- playsInline
233
- style={{ ...baseVideoStyle, opacity: showVideo && activeSlot === 0 ? 1 : 0, pointerEvents: activeSlot === 0 ? 'auto' : 'none', zIndex: activeSlot === 0 ? 1 : 0 }}
234
- />
235
- {/* Slot 1 */}
236
- <video
237
- ref={video1Ref}
238
- // See slot 0: anonymous CORS so R2 cross-origin clips aren't tainted
239
- // (which would mute the Web Audio graph).
240
- crossOrigin="anonymous"
241
- preload="auto"
242
- onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
243
- onTimeUpdate={() => { if (activeSlotRef.current === 1) handleTimeUpdate() }}
244
- onEnded={() => { if (activeSlotRef.current === 1) handleEnded() }}
245
- onPlay={() => { if (activeSlotRef.current === 1) setIsPlaying(true) }}
246
- onPause={() => { if (activeSlotRef.current === 1) handlePause() }}
247
- playsInline
248
- style={{ ...baseVideoStyle, opacity: showVideo && activeSlot === 1 ? 1 : 0, pointerEvents: activeSlot === 1 ? 'auto' : 'none', zIndex: activeSlot === 1 ? 1 : 0 }}
249
- />
474
+ {playback.mode === 'engine' ? (
475
+ <EngineSurface
476
+ attach={playback.attachCanvas}
477
+ picture={playback.status.picture}
478
+ reason={playback.status.reason}
479
+ debugHud={engine?.debugHud}
480
+ getStats={playback.getStats}
481
+ />
482
+ ) : (
483
+ <>
484
+ {/* Slot 0 */}
485
+ <video
486
+ ref={playback.video0Ref}
487
+ // Clips load cross-origin from R2; without this the media is CORS-tainted
488
+ // and the Web Audio createMediaElementSource graph outputs silence. R2
489
+ // sends Access-Control-Allow-Origin, so anonymous CORS keeps it audible.
490
+ crossOrigin="anonymous"
491
+ // Fetch enough to render the seeked poster frame on load (before play).
492
+ preload="auto"
493
+ onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
494
+ onTimeUpdate={() => { if (playback.activeSlotRef.current === 0) playback.handleTimeUpdate() }}
495
+ onEnded={() => { if (playback.activeSlotRef.current === 0) playback.handleEnded() }}
496
+ onError={() => playback.handleVideoError(0)}
497
+ onPlay={() => { if (playback.activeSlotRef.current === 0) playback.setIsPlaying(true) }}
498
+ onPause={() => { if (playback.activeSlotRef.current === 0) playback.handlePause() }}
499
+ playsInline
500
+ style={{ ...baseVideoStyle, opacity: showVideo && playback.activeSlot === 0 ? 1 : 0, pointerEvents: playback.activeSlot === 0 ? 'auto' : 'none', zIndex: playback.activeSlot === 0 ? 1 : 0 }}
501
+ />
502
+ {/* Slot 1 */}
503
+ <video
504
+ ref={playback.video1Ref}
505
+ // See slot 0: anonymous CORS so R2 cross-origin clips aren't tainted
506
+ // (which would mute the Web Audio graph).
507
+ crossOrigin="anonymous"
508
+ preload="auto"
509
+ onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
510
+ onTimeUpdate={() => { if (playback.activeSlotRef.current === 1) playback.handleTimeUpdate() }}
511
+ onEnded={() => { if (playback.activeSlotRef.current === 1) playback.handleEnded() }}
512
+ onError={() => playback.handleVideoError(1)}
513
+ onPlay={() => { if (playback.activeSlotRef.current === 1) playback.setIsPlaying(true) }}
514
+ onPause={() => { if (playback.activeSlotRef.current === 1) playback.handlePause() }}
515
+ playsInline
516
+ style={{ ...baseVideoStyle, opacity: showVideo && playback.activeSlot === 1 ? 1 : 0, pointerEvents: playback.activeSlot === 1 ? 'auto' : 'none', zIndex: playback.activeSlot === 1 ? 1 : 0 }}
517
+ />
518
+ </>
519
+ )}
250
520
  </div>
251
521
  )}
252
522
 
@@ -334,7 +604,7 @@ export default function PreviewPlayer({
334
604
  fileUrl={fileUrl}
335
605
  />
336
606
 
337
- {/* Audio elements are managed programmatically in useVideoPlayback */}
607
+ {/* Audio elements are managed programmatically in the playback hook */}
338
608
 
339
609
  {/* Caption preview */}
340
610
  {captionTrack && (
@@ -349,6 +619,17 @@ export default function PreviewPlayer({
349
619
  onCaptionSegmentChange={onCaptionSegmentChange}
350
620
  />
351
621
  )}
622
+
623
+ {/* Social-preview chrome — see `SocialSafeZoneOverlay` and the
624
+ `socialPreview` prop doc above. Rendered HERE, inside this same
625
+ `isolation: isolate` container as the video/overlays/captions above
626
+ and the play glyph below, so its z-index (48) is compared directly
627
+ against theirs rather than being evaluated one stacking context up
628
+ (where a positive z-index sibling of an isolated box paints over
629
+ the ENTIRE box, glyph included, regardless of that box's own
630
+ internal ordering). No-ops on an absent/unrecognized platform, so
631
+ this is unconditional. */}
632
+ <SocialSafeZoneOverlay platform={socialPreview} />
352
633
  </div>
353
634
  )
354
635
  }