@bycrux/editor 0.12.0 → 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 (266) 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 +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  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 +586 -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 +6 -2
  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 +2202 -251
  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/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,313 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { renderHook } from '@testing-library/react'
3
+ import { containsTime, resolveAt, sourceWindow } from '@bycrux/timeline-core'
4
+ import { useVideoPlayback } from '../useVideoPlayback'
5
+ import { engineSrcFor } from '../../../engine/scheduler'
6
+ import { withItemTracks } from '../../timeline/timeline-model'
7
+ import type { EditorProject, VisualItem } from '../../../schema'
8
+
9
+ // The fixture is imported by RELATIVE path, not as `@bycrux/timeline-core/fixtures/…`:
10
+ // the package's `exports` map declares only the `"."` entry, so subpath imports are
11
+ // not part of its public API and T6 does not get to widen it. Everything else in this
12
+ // file goes through the package's real barrel.
13
+ import negativeStart from '../../../../../timeline-core/fixtures/negative-start.json'
14
+ // SP3: T4's proxySrc-tier fixture — four tracks[0] clips spanning every
15
+ // combination of {proxySrc, nobg_preview_src} presence (proxy-00 neither,
16
+ // proxy-01 proxySrc only, proxy-10 nobg_preview_src only, proxy-11 both, where
17
+ // nobg wins). Its resolver golden lives at
18
+ // timeline-core/expected/proxy-matrix.json and is exercised end-to-end by
19
+ // timeline-core's own corpus.test.mjs; what's checked HERE is the same parity
20
+ // contract as negative-start above — that the editor hook's derived `clips`
21
+ // collection agrees with `resolveAt`/`sourceWindow` — now covering the new tier.
22
+ import proxyMatrix from '../../../../../timeline-core/fixtures/proxy-matrix.json'
23
+ import proxyMatrixExpected from '../../../../../timeline-core/expected/proxy-matrix.json'
24
+
25
+ // ── Corpus parity: useVideoPlayback's derived collections vs. the resolver ────
26
+ //
27
+ // `useVideoPlayback` does not expose a Scene. It exposes the editor's own
28
+ // PARTITION of the timeline — `clips` (tracks[0] videos, sorted by start),
29
+ // `tracks0NonVideo` (everything else on tracks[0]) and `overlayTracks`
30
+ // (tracks[1..]) — and each consumer applies the activation predicate itself.
31
+ // This test reassembles those three collections back into a flat, ordered scene
32
+ // and asserts it is exactly what `resolveAt(project, t, {variant: 'preview'})`
33
+ // reports: same items, same track indices, same kinds, same source windows.
34
+ //
35
+ // `negative-start.json` is the corpus's "background reel anchored before the
36
+ // origin" case: a tracks[0] video over [0, 6) with a tracks[1] JSX overlay over
37
+ // [-0.5, 5.5). It exercises the partition (a clip AND an overlay track), the
38
+ // half-open predicate at both ends, and a negative `start` — while keeping ONE
39
+ // item per track, so the same-trackIdx tie-order divergence (D7 in
40
+ // KNOWN-DIVERGENCES.md) can't muddy the comparison.
41
+ const project = negativeStart as unknown as EditorProject
42
+
43
+ // -0.25 → overlay only (before the clip starts, after the overlay does)
44
+ // 2.00 → both on screen
45
+ // 5.75 → clip only (the overlay ended at 5.5; the clip runs to 6)
46
+ const TIMESTAMPS = [-0.25, 2.0, 5.75]
47
+
48
+ interface FlatItem {
49
+ id: string
50
+ trackIdx: number
51
+ kind: string
52
+ }
53
+
54
+ /**
55
+ * Reassemble the editor's three derived collections into the resolver's flat,
56
+ * back-to-front scene: track 0 first, then each overlay track in order. Track
57
+ * indices are absolute (overlayTracks[0] is project track 1) because that is
58
+ * what `ResolvedItem.trackIdx` reports.
59
+ */
60
+ function editorSceneAt(
61
+ derived: {
62
+ clips: VisualItem[]
63
+ tracks0NonVideo: VisualItem[]
64
+ overlayTracks: VisualItem[][]
65
+ },
66
+ t: number,
67
+ ): FlatItem[] {
68
+ const flat: FlatItem[] = []
69
+ const push = (items: VisualItem[], trackIdx: number) => {
70
+ for (const item of items) {
71
+ if (containsTime(item.start, item.end, t)) flat.push({ id: item.id, trackIdx, kind: item.type })
72
+ }
73
+ }
74
+ push(derived.clips, 0)
75
+ push(derived.tracks0NonVideo, 0)
76
+ derived.overlayTracks.forEach((items, i) => push(items, i + 1))
77
+ return flat.sort((a, b) => a.trackIdx - b.trackIdx)
78
+ }
79
+
80
+ // `proj` defaults to the negative-start fixture so every existing call site
81
+ // below is unchanged; the proxy-matrix block passes its own project explicitly.
82
+ function resolverSceneAt(t: number, proj: EditorProject = project): FlatItem[] {
83
+ return resolveAt(withItemTracks(proj), t, { variant: 'preview' }).items.map((r) => ({
84
+ id: r.item.id as string,
85
+ trackIdx: r.trackIdx,
86
+ kind: r.kind,
87
+ }))
88
+ }
89
+
90
+ // Testing Library auto-cleans between tests, so the hook is mounted fresh per
91
+ // case at the timestamp under test rather than reused and re-rendered.
92
+ function derivedAt(t: number, proj: EditorProject = project) {
93
+ return renderHook(() => useVideoPlayback(proj, t, () => {}, (p) => p)).result.current
94
+ }
95
+
96
+ describe('useVideoPlayback derived collections vs. resolveAt (negative-start corpus fixture)', () => {
97
+ it.each(TIMESTAMPS)('agrees on what is on screen at t=%s', (t) => {
98
+ const { clips, tracks0NonVideo, overlayTracks } = derivedAt(t)
99
+ expect(editorSceneAt({ clips, tracks0NonVideo, overlayTracks }, t)).toEqual(resolverSceneAt(t))
100
+ })
101
+
102
+ it('splits the fixture the way the resolver labels it', () => {
103
+ const { clips, tracks0NonVideo, overlayTracks } = derivedAt(TIMESTAMPS[1])
104
+ expect(clips.map((c) => c.id)).toEqual(['mainClip'])
105
+ expect(tracks0NonVideo).toEqual([])
106
+ expect(overlayTracks.map((tr) => tr.map((i) => i.id))).toEqual([['bgReel']])
107
+ })
108
+
109
+ it.each(TIMESTAMPS)('seeks the active clip where the resolver says, at t=%s', (t) => {
110
+ const { clips } = derivedAt(t)
111
+ const scene = resolveAt(withItemTracks(project), t, { variant: 'preview' })
112
+ const videos = scene.items.filter((r) => r.kind === 'video')
113
+ // t=-0.25 is before the only clip starts — assert that, so a fixture change
114
+ // that silently empties this loop can't turn the case into a no-op.
115
+ expect(videos.length).toBe(t < 0 ? 0 : 1)
116
+ for (const resolved of videos) {
117
+ const clip = clips.find((c) => c.id === resolved.item.id)!
118
+ // What the hook assigns to `video.currentTime` for this clip at this
119
+ // instant (useVideoPlayback's scrub branch), against ResolvedItem.seek.
120
+ const inPoint = sourceWindow(clip, 'preview').inPoint
121
+ expect(Math.max(inPoint, inPoint + (t - clip.start))).toBeCloseTo(resolved.seek, 10)
122
+ expect(resolved.window).toEqual(sourceWindow(clip, 'preview'))
123
+ }
124
+ })
125
+
126
+ it('reports an empty scene past the end of every item (no last-clip fallback)', () => {
127
+ // The resolver has no fallback; PreviewPlayer's `?? clips[clips.length - 1]`
128
+ // is editor-side presentation and lives there, not here.
129
+ expect(resolverSceneAt(6.5)).toEqual([])
130
+ })
131
+ })
132
+
133
+ // ── Corpus parity: same contract, proxy-matrix fixture (SP3) ──────────────────
134
+ //
135
+ // proxy-matrix.json is four tracks[0] clips, one per combination of
136
+ // {proxySrc, nobg_preview_src}: proxy-00 has neither (falls through to
137
+ // normalizedSrc), proxy-01 has proxySrc only, proxy-10 has nobg_preview_src
138
+ // only, proxy-11 has both (nobg wins — see chooseSrcRaw in
139
+ // timeline-core/src/source-window.js). No overlay tracks, so this fixture
140
+ // complements negative-start rather than replacing it: it exercises the new
141
+ // tier through the SAME `clips` → `sourceWindow` path production code uses,
142
+ // while negative-start keeps covering the partition/overlay-track side.
143
+ const proxyMatrixProject = proxyMatrix as unknown as EditorProject
144
+
145
+ interface ProxyMatrixGolden {
146
+ sourceWindow: Record<string, { preview: unknown; render: unknown }>
147
+ }
148
+ const proxyMatrixGolden = proxyMatrixExpected as unknown as ProxyMatrixGolden
149
+
150
+ // One timestamp per clip (each clip spans 2s: [0,2), [2,4), [4,6), [6,8)),
151
+ // matching timeline-core/expected/proxy-matrix.json's resolveAt.preview.
152
+ const TIMESTAMPS_PROXY = [0, 2, 4, 6]
153
+
154
+ describe('useVideoPlayback derived collections vs. resolveAt (proxy-matrix corpus fixture)', () => {
155
+ it.each(TIMESTAMPS_PROXY)('agrees on what is on screen at t=%s', (t) => {
156
+ const { clips, tracks0NonVideo, overlayTracks } = derivedAt(t, proxyMatrixProject)
157
+ expect(editorSceneAt({ clips, tracks0NonVideo, overlayTracks }, t)).toEqual(resolverSceneAt(t, proxyMatrixProject))
158
+ })
159
+
160
+ it('splits the fixture the way the resolver labels it (four clips, no overlay tracks)', () => {
161
+ const { clips, tracks0NonVideo, overlayTracks } = derivedAt(TIMESTAMPS_PROXY[0], proxyMatrixProject)
162
+ expect(clips.map((c) => c.id)).toEqual(['proxy-00', 'proxy-01', 'proxy-10', 'proxy-11'])
163
+ expect(tracks0NonVideo).toEqual([])
164
+ expect(overlayTracks).toEqual([])
165
+ })
166
+
167
+ it.each(TIMESTAMPS_PROXY)('seeks the active clip where the resolver says, at t=%s', (t) => {
168
+ const { clips } = derivedAt(t, proxyMatrixProject)
169
+ const scene = resolveAt(withItemTracks(proxyMatrixProject), t, { variant: 'preview' })
170
+ const videos = scene.items.filter((r) => r.kind === 'video')
171
+ // Every fixture timestamp lands inside exactly one clip's [start, end) —
172
+ // assert that so a fixture edit that empties this loop can't go unnoticed.
173
+ expect(videos.length).toBe(1)
174
+ for (const resolved of videos) {
175
+ const clip = clips.find((c) => c.id === resolved.item.id)!
176
+ const inPoint = sourceWindow(clip, 'preview').inPoint
177
+ expect(Math.max(inPoint, inPoint + (t - clip.start))).toBeCloseTo(resolved.seek, 10)
178
+ expect(resolved.window).toEqual(sourceWindow(clip, 'preview'))
179
+ }
180
+ })
181
+
182
+ it('resolves the proxySrc/nobg_preview_src tier per clip the same way the resolver golden does', () => {
183
+ // Direct tier-selection check, independent of any timestamp: each clip's
184
+ // `sourceWindow(clip, 'preview').src` — reached through `clips`, the same
185
+ // collection `playbackSrcFor` consumes in useVideoPlayback.ts — must match
186
+ // the committed golden. This is what actually pins proxy-01 to its
187
+ // proxySrc and proxy-11 to nobg_preview_src (proxy over it) rather than
188
+ // just "some src or other".
189
+ const { clips } = derivedAt(TIMESTAMPS_PROXY[0], proxyMatrixProject)
190
+ expect(clips).toHaveLength(4)
191
+ for (const clip of clips) {
192
+ expect(sourceWindow(clip, 'preview')).toEqual(proxyMatrixGolden.sourceWindow[clip.id].preview)
193
+ }
194
+ })
195
+
196
+ // ── SP4 T6: the ENGINE path reads the same window ──────────────────────────
197
+ //
198
+ // The engine cannot open every src the preview chain can — the masters are
199
+ // 4K 10-bit HEVC and `nobg_preview_src` is VP9 WebM, while its demuxer is
200
+ // MP4-only and its decoder is configured for AV1 — so `engineSrcFor` narrows
201
+ // the chain's answer down to "the proxy, or nothing". What must NOT differ is
202
+ // the INPUT: both paths ask `sourceWindow(clip, 'preview')`, one shared
203
+ // resolver call, never a second copy of the precedence chain. This asserts
204
+ // that at the pure level, per clip, over the same corpus fixture:
205
+ //
206
+ // • whenever the engine accepts a clip, the src it accepts is bit-identical
207
+ // to the src the legacy player would load (the golden's `.src`);
208
+ // • whenever it declines, it declines because the SHARED window chose
209
+ // something other than the proxy — never because it disagreed about what
210
+ // the window is.
211
+ //
212
+ // proxy-11 is the case that matters: `nobg_preview_src` outranks the proxy in
213
+ // the chain, so the legacy player loads the alpha preview and the engine must
214
+ // decline rather than quietly decode the proxy (which still has the
215
+ // background the user just removed in it).
216
+ it('selects the engine-path src from the same sourceWindow results as the legacy path', () => {
217
+ const { clips } = derivedAt(TIMESTAMPS_PROXY[0], proxyMatrixProject)
218
+ const verdicts: Record<string, { src: string; blocked?: string }> = {}
219
+ for (const clip of clips) {
220
+ const golden = proxyMatrixGolden.sourceWindow[clip.id].preview as { src: string }
221
+ const window = sourceWindow(clip, 'preview')
222
+ // Same input as the legacy path, and as the committed golden.
223
+ expect(window.src).toBe(golden.src)
224
+ const verdict = engineSrcFor(clip, window)
225
+ // Accepted ⇒ identical to what legacy would load. Declined ⇒ empty.
226
+ expect(verdict.src === '' || verdict.src === golden.src).toBe(true)
227
+ verdicts[clip.id] = verdict
228
+ }
229
+ expect(verdicts).toEqual({
230
+ 'proxy-00': { src: '', blocked: 'no editing proxy yet' },
231
+ 'proxy-01': { src: (proxyMatrixGolden.sourceWindow['proxy-01'].preview as { src: string }).src },
232
+ 'proxy-10': { src: '', blocked: 'no editing proxy yet' },
233
+ 'proxy-11': { src: '', blocked: 'a higher-precedence preview source the engine cannot decode' },
234
+ })
235
+ })
236
+ })
237
+
238
+ // ── Reload effect: the clip-identity memo key must include proxySrc ───────────
239
+ //
240
+ // useVideoPlayback.ts:522-536 tracks clip identity (nobg_preview_src|proxySrc|
241
+ // src|inPoint|outPoint per clip) in a ref, and only reloads the active <video>
242
+ // element when that string changes — otherwise overlay-only edits would tear
243
+ // down and restart playback on every keystroke. SP3's proxySrc arrives via SSE
244
+ // mid-session: the proxy step finishes AFTER the project is already loaded and
245
+ // playing, so the field appears on a clip whose src the <video> element already
246
+ // has loaded. Without proxySrc in the identity string, that arrival is
247
+ // invisible to the effect and the preview never picks up the proxy.
248
+ //
249
+ // Fixtures can't exercise this — it's about the SAME logical clip gaining a
250
+ // field BETWEEN renders — so this uses renderHook's own rerender with two
251
+ // hand-built projects instead of a corpus fixture. And because this file is
252
+ // `.ts`, not `.tsx`, there's no JSX to mount a real `<video ref={...}>`
253
+ // through the render tree; the ref is attached directly to a detached
254
+ // `<video>` element after the first render instead. That's a legitimate way to
255
+ // drive this specific effect: it only ever reads `video0Ref.current` /
256
+ // `video1Ref.current`, never the DOM position of the element.
257
+ describe('useVideoPlayback reload effect — proxySrc identity (SP3 mid-session arrival)', () => {
258
+ // jsdom doesn't implement HTMLMediaElement.pause — the identity effect calls
259
+ // it on the inactive slot as part of a load. Stub it so the test exercises
260
+ // the effect without jsdom's "not implemented" console noise (same pattern
261
+ // as captionPositioning.test.tsx / VideoEditor.test.tsx, minus `play` and
262
+ // `AudioContext`: this test never calls play() or wires audio).
263
+ const pauseSpy = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
264
+ afterEach(() => { pauseSpy.mockClear() })
265
+
266
+ const baseClip = { id: 'c1', type: 'video' as const, start: 0, end: 10, inPoint: 0, outPoint: 10, src: '/corpus/orig.mp4' }
267
+ const projectWith = (clip: VisualItem): EditorProject => ({
268
+ id: 'reload-test',
269
+ status: 'draft',
270
+ settings: { resolution: [1080, 1920] },
271
+ tracks: [{ id: 'trk-0', items: [clip] }],
272
+ })
273
+
274
+ it('reloads only when proxySrc actually changes, not on every re-render', () => {
275
+ const { result, rerender } = renderHook(
276
+ ({ project }: { project: EditorProject }) => useVideoPlayback(project, 0, () => {}, (p) => p),
277
+ { initialProps: { project: projectWith({ ...baseClip }) } },
278
+ )
279
+
280
+ const active = document.createElement('video')
281
+ const inactive = document.createElement('video')
282
+ result.current.video0Ref.current = active
283
+ result.current.video1Ref.current = inactive
284
+
285
+ // Step 1 — warm-up load: the initial mount ran with video0Ref still null
286
+ // (attached above, after render), so its effect early-returned without
287
+ // recording an identity. This rerender is the first one the effect can
288
+ // actually act on; it must load the clip's src into the active slot.
289
+ inactive.setAttribute('src', 'sentinel')
290
+ rerender({ project: projectWith({ ...baseClip }) })
291
+ expect(active.src).toContain('/corpus/orig.mp4')
292
+ expect(inactive.hasAttribute('src')).toBe(false) // cleared as part of the load
293
+
294
+ // Step 2 — control: a brand-new project/clip object with IDENTICAL fields
295
+ // (same src, same in/outPoint, still no proxySrc) must produce the SAME
296
+ // identity string and skip the reload entirely. Re-arm the sentinel and
297
+ // confirm it survives untouched — proving the effect body did not run.
298
+ inactive.setAttribute('src', 'sentinel')
299
+ rerender({ project: projectWith({ ...baseClip }) })
300
+ expect(inactive.getAttribute('src')).toBe('sentinel')
301
+
302
+ // Step 3 — the fix under test: proxySrc appears on the SAME clip (id,
303
+ // src, in/outPoint all unchanged) — exactly what an SSE proxy-ready event
304
+ // looks like against an already-loaded project. This must be recognized
305
+ // as an identity change: the sentinel gets cleared again (the effect body
306
+ // ran) and the active slot reloads onto the proxy path. Without proxySrc
307
+ // in the identity string this step is indistinguishable from step 2 — the
308
+ // sentinel would survive and `active.src` would stay on the original file.
309
+ rerender({ project: projectWith({ ...baseClip, proxySrc: '/corpus/proxy.mp4' }) })
310
+ expect(inactive.hasAttribute('src')).toBe(false)
311
+ expect(active.src).toContain('/corpus/proxy.mp4')
312
+ })
313
+ })
@@ -1,11 +1,20 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import { sourceWindow } from '@bycrux/timeline-core'
2
3
  import { effectiveInPoint, effectiveOutPoint } from '../useVideoPlayback'
3
4
 
4
- // These helpers mirror render's collectAllItems rebase (render.js): when the
5
- // preview loads a normalizedSrc window cache (which starts at 0 and covers
6
- // [inPoint, outPoint] of the original), the effective seek inPoint is 0 and the
7
- // outPoint is rebased to the window length. nobg_preview_src is the full source
8
- // and must NOT rebase.
5
+ // `effectiveInPoint` / `effectiveOutPoint` are thin wrappers over
6
+ // @bycrux/timeline-core's `sourceWindow(clip, 'preview')` — the single
7
+ // implementation shared with render's collectAllItems (render.js). When the
8
+ // preview loads a normalizedSrc window cache (which plays from its own 0 and
9
+ // covers [normalizedInPoint, +duration] of the original), the points are
10
+ // rebased by the cache origin. nobg_preview_src is the full source and must NOT
11
+ // rebase.
12
+ //
13
+ // The cases below are unchanged from before the timeline-core rebase and are
14
+ // kept here as the EDITOR's own guard: they pin the numbers the preview seeks
15
+ // to, whatever the wrappers delegate to. The equivalence block at the bottom
16
+ // pins the delegation itself. timeline-core/test/source-window.test.mjs owns the
17
+ // exhaustive matrix (both variants, degenerate items, bad-variant throws).
9
18
 
10
19
  describe('effectiveInPoint', () => {
11
20
  it('rebases to 0 when normalizedSrc is the chosen src (legacy: no normalizedInPoint)', () => {
@@ -42,6 +51,10 @@ describe('effectiveInPoint', () => {
42
51
  effectiveInPoint({ inPoint: 6, normalizedInPoint: 5, normalizedSrc: '/cache/window.mp4' }),
43
52
  ).toBeCloseTo(1, 5)
44
53
  })
54
+
55
+ it('does NOT rebase when a full-source proxy outranks the window cache (SP3)', () => {
56
+ expect(effectiveInPoint({ inPoint: 6, normalizedInPoint: 5, normalizedSrc: '/cache/window.mp4', proxySrc: '/p.mp4' })).toBe(6)
57
+ })
45
58
  })
46
59
 
47
60
  describe('effectiveOutPoint', () => {
@@ -81,3 +94,23 @@ describe('effectiveOutPoint', () => {
81
94
  ).toBeCloseTo(15, 5)
82
95
  })
83
96
  })
97
+
98
+ // The wrappers must stay wrappers: if someone reintroduces a local copy of the
99
+ // rebase math here, this block fails the moment the two implementations drift.
100
+ describe('delegation to @bycrux/timeline-core', () => {
101
+ const CLIPS = [
102
+ { inPoint: 496.92, src: '/orig.mov' },
103
+ { inPoint: 496.92, outPoint: 514.92, normalizedSrc: '/cache/window.mp4' },
104
+ { inPoint: 496.92, outPoint: 514.92, nobg_preview_src: '/nobg.webm', normalizedSrc: '/c.mp4' },
105
+ { inPoint: 0.9157, outPoint: 16.97, normalizedInPoint: 0, normalizedSrc: '/cache/window.mp4' },
106
+ { inPoint: 6, outPoint: 20, normalizedInPoint: 5, normalizedSrc: '/cache/window.mp4' },
107
+ { src: '/orig.mov' },
108
+ {},
109
+ ]
110
+
111
+ it.each(CLIPS)('matches sourceWindow(clip, "preview") for %j', (clip) => {
112
+ const w = sourceWindow(clip, 'preview')
113
+ expect(effectiveInPoint(clip)).toBe(w.inPoint)
114
+ expect(effectiveOutPoint(clip)).toBe(w.outPoint)
115
+ })
116
+ })
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Track-level volume/mute in the legacy <video> preview.
3
+ *
4
+ * Two layers, deliberately:
5
+ *
6
+ * 1. `clipGain` — the fold itself, at the only place this hook turns audio
7
+ * settings into a number.
8
+ * 2. The hook, driven through its real effects and handlers, asserting that
9
+ * each of the four gain-setting sites actually routes through it. A pure
10
+ * helper that nothing calls would pass layer 1 and leave the preview as
11
+ * loud as it ever was.
12
+ *
13
+ * The GainNode is faked by pre-seeding `__montajGain` on the <video> elements:
14
+ * production caches the node there (`ensureVideoGain`) precisely so it survives
15
+ * remounts, and `getVideoGain` reads it without touching an AudioContext — so a
16
+ * seeded node is the wiring, not a stub around it. jsdom has no Web Audio at
17
+ * all, which is why the real one can't be used here.
18
+ */
19
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
20
+ import { act, renderHook } from '@testing-library/react'
21
+ import { clipGain, useVideoPlayback } from '../useVideoPlayback'
22
+ import type { EditorProject, VisualItem, VisualTrack } from '../../../schema'
23
+
24
+ // ── The fold ─────────────────────────────────────────────────────────────────
25
+
26
+ describe('clipGain', () => {
27
+ it('multiplies the track fader into the clip volume instead of replacing it', () => {
28
+ // Replacing would silently throw away per-clip work an editor already did.
29
+ expect(clipGain({ volume: 0.5 }, { volume: 0.8 })).toBeCloseTo(0.4, 10)
30
+ })
31
+
32
+ it('keeps the RATIO between two differently-set clips on the same track', () => {
33
+ // The property that makes "multiply" the right rule: pulling the track down
34
+ // must move every clip on it by the same factor, not flatten them onto one
35
+ // level. Two clips a 2:1 mix apart stay 2:1 apart.
36
+ const track: Pick<VisualTrack, 'volume'> = { volume: 0.5 }
37
+ const loud = clipGain(track, { volume: 0.8 })
38
+ const quiet = clipGain(track, { volume: 0.4 })
39
+ expect(loud / quiet).toBeCloseTo(2, 10)
40
+ expect(loud).toBeCloseTo(0.4, 10)
41
+ expect(quiet).toBeCloseTo(0.2, 10)
42
+ })
43
+
44
+ it('silences a clip on a muted track whatever its own volume says', () => {
45
+ expect(clipGain({ muted: true }, { volume: 2 })).toBe(0)
46
+ expect(clipGain({ muted: true, volume: 1 }, { volume: 1 })).toBe(0)
47
+ })
48
+
49
+ it('keeps a muted clip muted on an unmuted track', () => {
50
+ // Mute is either/or — the track cannot un-mute a clip.
51
+ expect(clipGain({ volume: 1 }, { muted: true, volume: 1 })).toBe(0)
52
+ expect(clipGain(undefined, { muted: true })).toBe(0)
53
+ })
54
+
55
+ it('leaves the clip untouched when the track carries no settings', () => {
56
+ // The overwhelmingly common case: nothing writes track volume/mute by
57
+ // default, so every project that predates the feature must sound identical.
58
+ expect(clipGain({}, { volume: 0.8 })).toBeCloseTo(0.8, 10)
59
+ expect(clipGain({}, {})).toBe(1)
60
+ expect(clipGain(undefined, { volume: 0.8 })).toBeCloseTo(0.8, 10)
61
+ expect(clipGain(undefined, {})).toBe(1)
62
+ })
63
+
64
+ it('carries amplification through both halves (gain above 1.0 is legal)', () => {
65
+ // GainNode, not video.volume — values > 1 amplify, and the fold must not
66
+ // clamp them on the way through.
67
+ expect(clipGain({ volume: 2 }, { volume: 1.5 })).toBeCloseTo(3, 10)
68
+ expect(clipGain({ volume: 0.5 }, { volume: 2 })).toBeCloseTo(1, 10)
69
+ })
70
+ })
71
+
72
+ // ── The four gain-setting sites ──────────────────────────────────────────────
73
+
74
+ interface FakeGain {
75
+ gain: { value: number }
76
+ /**
77
+ * Every value assigned to `gain.value`, in order.
78
+ *
79
+ * The transition sites (a cut, the gap clock) hand the incoming clip to the
80
+ * OTHER slot and then make that slot active, which re-runs the load-time
81
+ * effect over the same node. Asserting the final value alone would therefore
82
+ * pass even if the transition site itself wrote an unfolded number and the
83
+ * effect quietly corrected it a tick later. Asserting on every write pins the
84
+ * site under test.
85
+ */
86
+ writes: number[]
87
+ }
88
+
89
+ /** Every distinct value written to a node, rounded past float noise. */
90
+ function distinct(node: FakeGain): number[] {
91
+ return [...new Set(node.writes.map((v) => Math.round(v * 1e6) / 1e6))]
92
+ }
93
+
94
+ /** A <video> whose `currentTime` is a real read/write property (jsdom's is not). */
95
+ function fakeVideo(): HTMLVideoElement {
96
+ const el = document.createElement('video')
97
+ let t = 0
98
+ Object.defineProperty(el, 'currentTime', {
99
+ get: () => t,
100
+ set: (v: number) => { t = v },
101
+ configurable: true,
102
+ })
103
+ return el
104
+ }
105
+
106
+ /** Seed the cached GainNode the hook would otherwise build on first play. */
107
+ function attachGain(el: HTMLVideoElement): FakeGain {
108
+ const writes: number[] = []
109
+ let value = Number.NaN
110
+ const node: FakeGain = {
111
+ gain: {
112
+ get value() { return value },
113
+ set value(next: number) { value = next; writes.push(next) },
114
+ },
115
+ writes,
116
+ }
117
+ ;(el as unknown as { __montajGain: FakeGain }).__montajGain = node
118
+ return node
119
+ }
120
+
121
+ const clip = (over: Partial<VisualItem>): VisualItem =>
122
+ ({ id: 'a', type: 'video', src: '/a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, ...over }) as VisualItem
123
+
124
+ function projectWith(items: VisualItem[], track: Partial<VisualTrack> = {}): EditorProject {
125
+ return {
126
+ id: 'track-audio',
127
+ status: 'draft',
128
+ settings: { resolution: [1080, 1920] },
129
+ tracks: [{ id: 'trk-0', items, ...track }],
130
+ } as EditorProject
131
+ }
132
+
133
+ /**
134
+ * Mount the hook, hand it two wired <video> slots, and let the load effect run.
135
+ *
136
+ * The mount render sees both refs still null (they are assigned after it), so
137
+ * nothing has touched a slot yet; the rerender with a fresh project object is
138
+ * the first pass whose effects can load the active slot and set its gain.
139
+ */
140
+ function mount(project: EditorProject) {
141
+ const harness = renderHook(
142
+ ({ p }: { p: EditorProject }) => useVideoPlayback(p, 0, () => {}, (path) => path),
143
+ { initialProps: { p: project } },
144
+ )
145
+ const v0 = fakeVideo()
146
+ const v1 = fakeVideo()
147
+ const g0 = attachGain(v0)
148
+ const g1 = attachGain(v1)
149
+ harness.result.current.video0Ref.current = v0
150
+ harness.result.current.video1Ref.current = v1
151
+ harness.rerender({ p: { ...project } })
152
+ return { ...harness, v0, v1, g0, g1 }
153
+ }
154
+
155
+ describe('useVideoPlayback — the track fold reaches the GainNodes', () => {
156
+ // jsdom implements neither play nor pause; the load, boundary and gap paths
157
+ // all call them. Same stubbing pattern as useVideoPlayback.corpus.test.ts.
158
+ let playSpy: ReturnType<typeof vi.spyOn>
159
+ let pauseSpy: ReturnType<typeof vi.spyOn>
160
+ beforeEach(() => {
161
+ playSpy = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
162
+ pauseSpy = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
163
+ })
164
+ afterEach(() => {
165
+ playSpy.mockRestore()
166
+ pauseSpy.mockRestore()
167
+ vi.restoreAllMocks()
168
+ })
169
+
170
+ it('folds the track fader into the active clip on load', () => {
171
+ const { g0 } = mount(projectWith([clip({ volume: 0.8 })], { volume: 0.5 }))
172
+ expect(g0.gain.value).toBeCloseTo(0.4, 10)
173
+ })
174
+
175
+ it('leaves the clip alone when the track carries no settings', () => {
176
+ const { g0 } = mount(projectWith([clip({ volume: 0.8 })]))
177
+ expect(g0.gain.value).toBeCloseTo(0.8, 10)
178
+ })
179
+
180
+ it('silences the active clip when the TRACK is muted', () => {
181
+ const { g0 } = mount(projectWith([clip({ volume: 2 })], { muted: true }))
182
+ expect(g0.gain.value).toBe(0)
183
+ })
184
+
185
+ it('keeps a clip muted on an unmuted track muted', () => {
186
+ const { g0 } = mount(projectWith([clip({ muted: true, volume: 1 })], { volume: 1 }))
187
+ expect(g0.gain.value).toBe(0)
188
+ })
189
+
190
+ it('re-applies the gain when only the TRACK volume changes', () => {
191
+ // Moving the track fader edits no clip, so this is the only thing that can
192
+ // make the change audible on a slot that is already loaded and playing.
193
+ const items = [clip({ volume: 0.8 })]
194
+ const { rerender, g0 } = mount(projectWith(items, { volume: 0.5 }))
195
+ expect(g0.gain.value).toBeCloseTo(0.4, 10)
196
+
197
+ // Same item objects, new track volume — the load effect's identity string
198
+ // is unchanged, so nothing reloads; only the gain moves.
199
+ act(() => { rerender({ p: projectWith(items, { volume: 0.25 }) }) })
200
+ expect(g0.gain.value).toBeCloseTo(0.2, 10)
201
+ })
202
+
203
+ it('preserves the mix between two clips when the track is pulled down (preload site)', () => {
204
+ // Clip A is the active slot's gain; clip B's is set by the next-clip
205
+ // preload inside handleTimeUpdate. Both go through the fold, so the 2:1
206
+ // ratio the editor set survives the track fader.
207
+ const a = clip({ id: 'a', volume: 0.8, start: 0, end: 5 })
208
+ const b = clip({ id: 'b', src: '/b.mp4', volume: 0.4, start: 5, end: 10 })
209
+ const { result, v0, g0, g1 } = mount(projectWith([a, b], { volume: 0.5 }))
210
+
211
+ v0.currentTime = 1 // mid-clip: preload runs, the boundary branch does not
212
+ act(() => { result.current.handleTimeUpdate() })
213
+
214
+ expect(distinct(g0)).toEqual([0.4])
215
+ expect(distinct(g1)).toEqual([0.2])
216
+ expect(g0.gain.value / g1.gain.value).toBeCloseTo(2, 10)
217
+ })
218
+
219
+ it('folds the track into the incoming clip at a contiguous cut', () => {
220
+ const a = clip({ id: 'a', volume: 0.8, start: 0, end: 5, outPoint: 5 })
221
+ const b = clip({ id: 'b', src: '/b.mp4', volume: 0.4, start: 5, end: 10 })
222
+ const { result, v0, g1 } = mount(projectWith([a, b], { volume: 0.5 }))
223
+
224
+ // First pass preloads slot 1 (asserted above); forget those writes so what
225
+ // the switch site itself sets is what this case measures.
226
+ v0.currentTime = 1
227
+ act(() => { result.current.handleTimeUpdate() })
228
+ g1.writes.length = 0
229
+
230
+ v0.currentTime = 5 // at outPoint, and b starts exactly where a ends
231
+ act(() => { result.current.handleTimeUpdate() })
232
+
233
+ expect(distinct(g1)).toEqual([0.2])
234
+ })
235
+
236
+ it('folds the track into the clip the gap clock lands on', () => {
237
+ const a = clip({ id: 'a', volume: 0.8, start: 0, end: 2, outPoint: 2 })
238
+ const b = clip({ id: 'b', src: '/b.mp4', volume: 0.4, start: 3, end: 5 })
239
+
240
+ // Drive the gap rAF by hand: collect every scheduled callback, then jump
241
+ // the wall clock past the gap so the first tick lands on b's start. The
242
+ // gap branch also sets isPlaying, which starts the boundary-polling pump —
243
+ // hence a queue rather than a single slot, and `[0]`, which is the gap
244
+ // tick the handler scheduled synchronously.
245
+ const scheduled: FrameRequestCallback[] = []
246
+ vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
247
+ scheduled.push(cb)
248
+ return scheduled.length
249
+ })
250
+ const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(0)
251
+
252
+ const { result, v0, g1 } = mount(projectWith([a, b], { volume: 0.5 }))
253
+ scheduled.length = 0
254
+ v0.currentTime = 2 // at a's outPoint, and b starts 1s later — a gap, not a cut
255
+ act(() => { result.current.handleTimeUpdate() })
256
+ expect(scheduled.length).toBeGreaterThan(0)
257
+
258
+ g1.writes.length = 0 // forget what the preload wrote; the gap site is under test
259
+ nowSpy.mockReturnValue(10_000)
260
+ act(() => { scheduled[0](10_000) })
261
+
262
+ expect(distinct(g1)).toEqual([0.2])
263
+ })
264
+
265
+ it('silences the incoming clip too when the track is muted', () => {
266
+ // The transition sites have their own gain assignment; a track mute that
267
+ // only held for the clip already on screen would leak audio at every cut.
268
+ const a = clip({ id: 'a', volume: 1, start: 0, end: 5, outPoint: 5 })
269
+ const b = clip({ id: 'b', src: '/b.mp4', volume: 1, start: 5, end: 10 })
270
+ const { result, v0, g0, g1 } = mount(projectWith([a, b], { muted: true }))
271
+
272
+ v0.currentTime = 1
273
+ act(() => { result.current.handleTimeUpdate() })
274
+
275
+ expect(distinct(g0)).toEqual([0])
276
+ expect(distinct(g1)).toEqual([0])
277
+ })
278
+ })