@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,90 +1,120 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import type { EditorProject as Project } from '../../schema'
3
-
4
- // Typed extension for the shared AudioContext cached on window
5
- interface MontajWindow {
6
- __montajSharedCtx?: AudioContext
7
- }
2
+ import {
3
+ sourceWindow,
4
+ playbackSrcFor as resolvePlaybackSrc,
5
+ projectEnd as timelineProjectEnd,
6
+ audioWindow,
7
+ } from '@bycrux/timeline-core'
8
+ import { gateProxy, isProxyUsable, markProxyFailed } from './proxySupport'
9
+ import {
10
+ getSharedAudioContext,
11
+ latencySeconds,
12
+ peekSharedAudioContext,
13
+ resumeAudioContextFromGesture,
14
+ type MontajWindow,
15
+ } from './audio-context'
16
+ import type { EditorProject as Project, VisualItem, VisualTrack } from '../../schema'
17
+ import { effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../timeline/timeline-model'
8
18
 
9
19
  // Typed extension for video elements that cache their GainNode
10
20
  interface MontajVideoElement extends HTMLVideoElement {
11
21
  __montajGain?: GainNode
12
22
  }
13
23
 
24
+ // ── Source-window helpers ───────────────────────────────────────────────────
25
+ //
26
+ // The three functions below are thin wrappers over @bycrux/timeline-core with
27
+ // the variant pinned to 'preview'. The canonical implementation — and the full
28
+ // reasoning, with both the editor and render originals quoted verbatim next to
29
+ // the branch that reproduces them — lives in
30
+ // `montaj_assets/timeline-core/src/source-window.js`. Read that module's header
31
+ // before changing anything here; the same math also drives the render engine,
32
+ // and preview/render differences are deliberate, not accidental.
33
+ //
34
+ // The short version of why these exist at all:
35
+ //
36
+ // • `normalizedSrc` is a per-WINDOW normalized cache: it covers exactly
37
+ // [normalizedInPoint, normalizedInPoint + duration] of the original source
38
+ // and plays from its own time 0. Items always store inPoint/outPoint in
39
+ // ORIGINAL-source coordinates, so whenever the cache is the loaded src the
40
+ // points must be rebased by the cache origin
41
+ // (`normalizedInPoint ?? inPoint ?? 0`) or every seek lands in the wrong
42
+ // place. When `normalizedInPoint` is absent (legacy caches) the origin
43
+ // collapses to `inPoint`, reproducing the old rebase-to-0 behavior.
44
+ //
45
+ // • `nobg_preview_src` (VP9 WebM with alpha) covers the FULL source, not a
46
+ // window, so it is NOT rebased — and it takes precedence over the cache.
47
+ // `nobg_src` is the ProRes 4444 render-only artifact and is never loaded
48
+ // into a <video> element; browsers can't decode ProRes.
49
+ //
50
+ // • `proxySrc` (SP3) is the full-source, all-intra 720p H.264+Opus editing
51
+ // proxy generated automatically at import. It also covers the FULL
52
+ // source, so it is NOT rebased either — same shape as `nobg_preview_src`.
53
+ // It sits AFTER `nobg_preview_src` in the precedence chain (an alpha
54
+ // preview always wins over the plain proxy), and is preview-only: render
55
+ // never reads it, and it is never written into project.json by render.
56
+
57
+ // SP3 precedence, most to least specific: nobg_preview_src > proxySrc >
58
+ // normalizedSrc (rebased) > src.
59
+
14
60
  /**
15
61
  * Resolve the file path the browser should load for previewing this clip.
16
62
  *
17
- * For bg-removed clips, prefer `nobg_preview_src` (small WebM with alpha,
18
- * browser-friendly) over `src` (the original raw file with bg present), so
19
- * the preview pane shows what the final render will composite — not the
20
- * un-cut-out source. Mirrors the same pattern used by
21
- * `OverlayItemsLayer.tsx:406` for tracks[1+] items; this generalises it to
22
- * the main-track preview so bg-removed clips placed on tracks[0] don't show
23
- * the wrong layer in preview while rendering correctly. Falls back to `src`
24
- * when `nobg_preview_src` is absent (most clips).
25
- *
26
- * Note: `nobg_src` is the ProRes 4444 render-only artifact and is NEVER
27
- * loaded into a `<video>` element — browsers can't decode ProRes.
63
+ * `'preview'` always yields a string (`''` when the clip has no src field at
64
+ * all) — the `?? ''` below is a type narrowing, not a runtime fallback; see the
65
+ * `src` note on `SourceWindow` in timeline-core's index.d.ts.
28
66
  */
29
- function playbackSrcFor(clip: { src?: string; nobg_preview_src?: string; normalizedSrc?: string }): string {
30
- return clip.nobg_preview_src ?? clip.normalizedSrc ?? clip.src ?? ''
67
+ function playbackSrcFor(clip: { src?: string; nobg_preview_src?: string; normalizedSrc?: string; proxySrc?: string }): string {
68
+ // gateProxy (SP3 fix B2): strip an unsupported/failed proxy BEFORE the tier
69
+ // chain runs, so unsupported browsers fall through to normalizedSrc/src
70
+ // instead of a silently-black <video>.
71
+ return resolvePlaybackSrc(gateProxy(clip), 'preview') ?? ''
31
72
  }
32
73
 
33
74
  /**
34
75
  * The inPoint the preview should SEEK to for this clip, accounting for the
35
- * normalizedSrc cache origin.
36
- *
37
- * A `normalizedSrc` cache is a trimmed file that covers exactly
38
- * [normalizedInPoint, normalizedInPoint + duration] of the original source and
39
- * plays starting at time 0. When `playbackSrcFor` chooses the cache as the
40
- * playback src, we must rebase by the cache origin so the seek position is
41
- * relative to the cache's own timeline.
42
- *
43
- * The origin is `clip.normalizedInPoint ?? clip.inPoint ?? 0`:
44
- * - When `normalizedInPoint` is set, the cache was built for a specific window
45
- * that may differ from the current inPoint (e.g. after a start-trim): the
46
- * cache still covers the new window, but we must subtract the origin so the
47
- * seek lands at the right position inside the cache.
48
- * - When `normalizedInPoint` is absent (legacy), the cache was built assuming
49
- * it starts at the clip's inPoint → origin = inPoint → effectiveInPoint = 0
50
- * (reproduces the old rebase-to-0 behavior).
51
- *
52
- * This mirrors render's `collectAllItems` (montaj_assets/render/render.js),
53
- * which rebases inPoint by the same origin.
54
- *
55
- * The rebase applies ONLY when the cache is actually the chosen src.
56
- * `nobg_preview_src` takes precedence in `playbackSrcFor` and is NOT a window
57
- * cache (it covers the full source), so it keeps the original inPoint — exactly
58
- * as render's nobg path does.
76
+ * normalizedSrc cache origin. `sourceWindow(clip, 'preview').inPoint`.
59
77
  */
60
- export function effectiveInPoint(clip: { inPoint?: number; normalizedInPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; src?: string }): number {
61
- const usingNormalizedCache = !clip.nobg_preview_src && !!clip.normalizedSrc
62
- if (!usingNormalizedCache) return clip.inPoint ?? 0
63
- const origin = clip.normalizedInPoint ?? clip.inPoint ?? 0
64
- return (clip.inPoint ?? 0) - origin
78
+ export function effectiveInPoint(clip: { inPoint?: number; normalizedInPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; proxySrc?: string; src?: string }): number {
79
+ return sourceWindow(gateProxy(clip), 'preview').inPoint
65
80
  }
66
81
 
67
82
  /**
68
- * The outPoint in the loaded src's own timeline. For a normalizedSrc cache the
69
- * stored `clip.outPoint` is in ORIGINAL-source coordinates while the cache
70
- * plays from its own time origin; the boundary/loop checks compare against
71
- * `video.currentTime` (cache time), so the outPoint must be rebased by the
72
- * cache origin.
73
- *
74
- * The origin is `clip.normalizedInPoint ?? clip.inPoint ?? 0` (same as
75
- * effectiveInPoint). Legacy clips without `normalizedInPoint` default the
76
- * origin to inPoint, reproducing the old (outPoint - inPoint) window-length
77
- * behavior.
83
+ * The outPoint in the loaded src's own timeline, rebased by the cache origin
84
+ * when the normalizedSrc cache is what's actually loaded (the boundary/loop
85
+ * checks below compare against `video.currentTime`, i.e. cache time).
86
+ * `sourceWindow(clip, 'preview').outPoint`.
78
87
  *
79
88
  * Returns undefined when no outPoint is stored, so callers keep their existing
80
89
  * fallback (clip.end - clip.start + effectiveInPoint).
81
90
  */
82
- export function effectiveOutPoint(clip: { inPoint?: number; outPoint?: number; normalizedInPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; src?: string }): number | undefined {
83
- if (clip.outPoint == null) return undefined
84
- const usingNormalizedCache = !clip.nobg_preview_src && !!clip.normalizedSrc
85
- if (!usingNormalizedCache) return clip.outPoint
86
- const origin = clip.normalizedInPoint ?? clip.inPoint ?? 0
87
- return clip.outPoint - origin
91
+ export function effectiveOutPoint(clip: { inPoint?: number; outPoint?: number; normalizedInPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; proxySrc?: string; src?: string }): number | undefined {
92
+ return sourceWindow(gateProxy(clip), 'preview').outPoint
93
+ }
94
+
95
+ /**
96
+ * The GainNode value for one clip on `track` — the ONLY place this hook turns
97
+ * audio settings into a number.
98
+ *
99
+ * Track settings fold into the clip's own via `effectiveItemAudio`: volume
100
+ * MULTIPLIES (a clip the editor already turned down stays proportionally
101
+ * quieter when its track is pulled down) and mute is either/or. `track` is
102
+ * `undefined` for a project with no tracks at all, and its `volume`/`muted` are
103
+ * absent on every track nobody has touched — both reduce to the clip's own
104
+ * values, which is what every project got before track settings existed.
105
+ *
106
+ * The four gain-setting sites below all route through this rather than reading
107
+ * `clip.muted`/`clip.volume` themselves. Folding by writing the effective
108
+ * values ONTO the clip is not an option: `clips` holds the project's own item
109
+ * objects by reference (see `enabledTrackItems`), and the renderer and the
110
+ * server both mutate those in place.
111
+ */
112
+ export function clipGain(
113
+ track: Pick<VisualTrack, 'volume' | 'muted'> | undefined,
114
+ clip: Pick<VisualItem, 'volume' | 'muted'>,
115
+ ): number {
116
+ const { volume, muted } = effectiveItemAudio(track, clip)
117
+ return muted ? 0 : volume
88
118
  }
89
119
 
90
120
  export function useVideoPlayback(
@@ -123,6 +153,10 @@ export function useVideoPlayback(
123
153
  // Keep ref in sync so effects with narrow deps can read current playing state
124
154
  useEffect(() => { isPlayingRef.current = isPlaying }, [isPlaying])
125
155
  const [showVideo, setShowVideo] = useState(true)
156
+ // Bumped when a proxy fails to decode (SP3 fix B2) — the clip-identity
157
+ // effect depends on it, so the bump forces the reload that drops the
158
+ // now-suppressed proxy tier.
159
+ const [proxyFailTick, setProxyFailTick] = useState(0)
126
160
 
127
161
  // Gap clock — advances time through lift-style gaps between primary clips
128
162
  const gapRAFRef = useRef<number | null>(null)
@@ -136,72 +170,66 @@ export function useVideoPlayback(
136
170
  const fileUrlRef = useRef(fileUrl)
137
171
  useEffect(() => { fileUrlRef.current = fileUrl }, [fileUrl])
138
172
 
173
+ // The AUDIBLE-time bridge to the store. Every playback-advance emission in
174
+ // this hook (rAF canvas tick, gap tick, `handleTimeUpdate`, clip-switch and
175
+ // end-of-clip snaps) routes through this instead of calling `onTimeUpdate`
176
+ // directly. The `<video>` element's `currentTime` and the RAF wall-clock both
177
+ // report the same frames-consumed lead the engine's master clock does — the
178
+ // audio graph is shared — so the picture leads the ear by exactly
179
+ // `latencySeconds` unless we subtract it here. Read live off the shared ctx
180
+ // so device switches take effect on the very next emission; clamp ≥ 0.
181
+ // Scrubs never come through here: the scrub effect updates `lastTimeRef`
182
+ // without emitting, so seek/scrub display is unaffected.
183
+ const emitTime = useCallback((t: number) => {
184
+ // `peek` never creates: production reaches this only during playback, whose
185
+ // gesture-anchored `togglePlay` has already minted the shared ctx — no ctx
186
+ // means no gesture yet, so the frames-consumed clock has nothing audible
187
+ // behind it to lag, and pass-through is the correct behavior.
188
+ const ctx = peekSharedAudioContext()
189
+ onTimeUpdate(ctx ? Math.max(0, t - latencySeconds(ctx)) : t)
190
+ }, [onTimeUpdate])
191
+
139
192
  function getActiveVideo() { return activeSlotRef.current === 0 ? video0Ref.current : video1Ref.current }
140
193
  function getInactiveVideo() { return activeSlotRef.current === 0 ? video1Ref.current : video0Ref.current }
141
194
 
142
195
  // ── Video timeline ─────────────────────────────────────────────────────────
143
196
  // Only video items drive the double-buffer player; non-video items (images, etc.)
144
197
  // in tracks[0] are exposed separately for the preview to render as a background layer.
145
- const clips = useMemo(() => (project.tracks?.[0] ?? []).filter(c => c.type === 'video').sort((a, b) => a.start - b.start), [project])
146
- const tracks0NonVideo = useMemo(() => (project.tracks?.[0] ?? []).filter(c => c.type !== 'video'), [project])
147
- const overlayTracks = useMemo(() => project.tracks?.slice(1) ?? [], [project])
198
+ const clips = useMemo(() => (enabledTrackItems(project)[0] ?? []).filter(c => c.type === 'video').sort((a, b) => a.start - b.start), [project])
199
+ const tracks0NonVideo = useMemo(() => (enabledTrackItems(project)[0] ?? []).filter(c => c.type !== 'video'), [project])
200
+ const overlayTracks = useMemo(() => enabledTrackItems(project).slice(1), [project])
201
+
202
+ // The track `clips` came out of, kept alongside them because its own
203
+ // volume/mute fold into every clip on it (see `clipGain`).
204
+ // `enabledTrackItems(project)[0]` hands back items and drops the track object
205
+ // that carries those settings; `enabledTracks` is the same filter in the same
206
+ // order, so `[0]` here is `[0]` there by construction.
207
+ const videoTrack = useMemo(() => enabledTracks(project)[0], [project])
148
208
 
149
209
  // Canvas project: no primary video in tracks[0] (e.g. image-only background track)
150
210
  const isCanvasProject = clips.length === 0
151
211
 
152
212
  // Total project end — includes opaque overlays and audio that extend beyond video clips.
153
213
  // Used to decide whether to keep playing after the last video clip ends.
154
- const projectEnd = useMemo(() => {
155
- const videoEnd = clips.length > 0 ? clips[clips.length - 1].end : 0
156
- const overlayEnd = overlayTracks.flat().reduce((m, i) => Math.max(m, i.end), 0)
157
- const audioEnd = (project.audio?.tracks ?? []).reduce((m, t) => Math.max(m, t.end ?? 0), 0)
158
- return Math.max(videoEnd, overlayEnd, audioEnd)
159
- }, [clips, overlayTracks, project.audio?.tracks])
214
+ //
215
+ // SP4 T8: this VIDEO-mode formula is `@bycrux/timeline-core`'s `projectEnd`
216
+ // (`src/durations.js`) ported verbatim FROM this exact memo — the two are
217
+ // byte-identical for every well-formed project, so calling the shared
218
+ // implementation here is a no-op substitution, not a behavior change. This
219
+ // is deliberately NOT shared with the CANVAS-mode ceiling below
220
+ // (`canvasMaxEndRef`, which excludes audio) — the two ends are legitimately
221
+ // different formulas (see `durations.js`'s module header) and only this one
222
+ // has a shared home.
223
+ const projectEnd = useMemo(() => timelineProjectEnd(withEnabledItemTracks(project)), [project])
160
224
 
161
225
  // Wire a video slot through a Web Audio GainNode (once per element — createMediaElementSource
162
226
  // can only be called once). After this, video.volume/muted have no audible effect; all volume
163
227
  // control goes through the GainNode, which supports values > 1.0 for amplification.
164
228
  //
165
- // CRITICAL: ALL slots AND ALL audio tracks share the SAME AudioContext. Fresh
166
- // AudioContexts start suspended and require a user gesture to resume. Creating
167
- // separate contexts (one per slot, or a separate one for audio tracks) means
168
- // those born inside a useEffect have no user-gesture activation — they stay
169
- // suspended → silent. Worse, video frame production is gated on the wired
170
- // AudioContext clock running, so a suspended context with a wired <video>
171
- // produces "video plays but no frames render" after a hard refresh.
172
- // Stash the single shared context on `window` so it survives strict-mode remounts
173
- // and is reused across audio tracks, video slots, and re-mounts.
174
- function getSharedAudioContext(): AudioContext {
175
- const w = window as Window & MontajWindow
176
- // If the cached context is closed (shouldn't happen, but defensive), recreate.
177
- if (!w.__montajSharedCtx || w.__montajSharedCtx.state === 'closed') {
178
- w.__montajSharedCtx = new AudioContext()
179
- }
180
- return w.__montajSharedCtx
181
- }
182
-
183
- /**
184
- * MUST be called from inside a user-gesture handler (keydown, click, etc.).
185
- * Browsers credit a `resume()` call as gesture-driven only when it happens
186
- * synchronously inside a gesture-rooted call stack. Calling resume() from
187
- * a useEffect or a setTimeout silently fails — the context stays suspended.
188
- *
189
- * Symptom of a suspended context with a wired <video>: video appears to
190
- * play (paused=false, currentTime advances internally per the DOM clock)
191
- * but no frames render and no audio plays — the entire pipeline is gated
192
- * on the AudioContext clock running. This bites specifically after a page
193
- * refresh, because SPA navigation carries gesture activation across pages
194
- * but a hard reload does not.
195
- */
196
- function resumeAudioContextFromGesture() {
197
- const w = window as Window & MontajWindow
198
- const ctx: AudioContext | undefined = w.__montajSharedCtx
199
- if (ctx && ctx.state === 'suspended') {
200
- // Fire-and-forget; resume() returns a Promise but the gesture-credit
201
- // happens at the synchronous call site, not when the promise resolves.
202
- ctx.resume().catch(() => {})
203
- }
204
- }
229
+ // `getSharedAudioContext` / `resumeAudioContextFromGesture` used to live here as
230
+ // private helpers; SP4 T4 MOVED them to `./audio-context` (unchanged) so the
231
+ // WebCodecs engine consumes the same context and the same gesture rule instead
232
+ // of growing a second copy. Their full rationale lives in that module's header.
205
233
 
206
234
  /**
207
235
  * Start playback on a wired <video> from a user gesture. Video frame
@@ -274,22 +302,25 @@ export function useVideoPlayback(
274
302
  }
275
303
 
276
304
  // Set video clip volume via GainNode (supports amplification > 1.0). Muted clips
277
- // get gain 0; unmuted clips get the clip's volume value. No-op until the slot is
305
+ // — or clips on a muted track — get gain 0; the rest get the clip's volume
306
+ // scaled by its track's (`clipGain`). No-op until the slot is
278
307
  // wired (first play) — there's no audio to control on a paused poster, and
279
308
  // wiring here would gate the poster frame on the suspended context.
280
309
  function applyClipVolume(clip: { muted?: boolean; volume?: number }) {
281
310
  const slot = activeSlotRef.current
282
311
  const gain = getVideoGain(slot)
283
- if (gain) gain.gain.value = clip.muted ? 0 : (clip.volume ?? 1)
312
+ if (gain) gain.gain.value = clipGain(videoTrack, clip)
284
313
  }
285
314
 
286
- // Apply video clip volume via Web Audio GainNode (supports > 1.0 amplification)
315
+ // Apply video clip volume via Web Audio GainNode (supports > 1.0 amplification).
316
+ // `videoTrack` is a dep in its own right: pulling the TRACK's fader while the
317
+ // clips themselves are untouched has to reach the live gain node too.
287
318
  useEffect(() => {
288
319
  const idx = activeIdxRef.current
289
320
  const clip = clips[idx]
290
321
  if (!clip) return
291
322
  applyClipVolume(clip)
292
- }, [clips, activeSlot])
323
+ }, [clips, videoTrack, activeSlot])
293
324
 
294
325
  // maxEnd for the canvas rAF clock — the furthest overlay/caption end. Kept in
295
326
  // a ref, updated by its own cheap effect, so the rAF effect below doesn't tear
@@ -313,7 +344,7 @@ export function useVideoPlayback(
313
344
  const dt = (ms - rafLastMs.current) / 1000
314
345
  const next = Math.min(lastTimeRef.current + dt, maxEnd)
315
346
  lastTimeRef.current = next
316
- onTimeUpdate(next)
347
+ emitTime(next)
317
348
  if (next >= maxEnd) {
318
349
  setIsPlaying(false)
319
350
  rafRef.current = null
@@ -336,7 +367,7 @@ export function useVideoPlayback(
336
367
  return () => {
337
368
  if (rafRef.current) cancelAnimationFrame(rafRef.current)
338
369
  }
339
- }, [isPlaying, isCanvasProject, onTimeUpdate])
370
+ }, [isPlaying, isCanvasProject, emitTime])
340
371
 
341
372
  // ── Multi-track audio management ───────────────────────────────────────────
342
373
  // Derive unmuted tracks. The full tracks array is a new reference on every
@@ -432,54 +463,33 @@ export function useVideoPlayback(
432
463
  const unmutedAudioTracksRef = useRef(unmutedAudioTracks)
433
464
  useEffect(() => { unmutedAudioTracksRef.current = unmutedAudioTracks }, [unmutedAudioTracks])
434
465
 
466
+ // SP4 T8: the window/gain arithmetic is routed through timeline-core's
467
+ // `audioWindow` (`src/audio.js`) — the pure port of exactly this logic,
468
+ // derived-outPoint rule included — the same way `useEnginePlayback.ts`
469
+ // already does for the WebCodecs engine path. The 0.3s re-seek threshold
470
+ // and all other imperative behavior (play/pause calls, gain writes) are
471
+ // kept exactly; only the pure MATH moved to the shared implementation.
435
472
  const syncAudioTracks = useCallback(function syncAudioTracks(playhead: number, playing: boolean) {
436
473
  for (const track of unmutedAudioTracksRef.current) {
437
474
  const el = audioRefsMap.current.get(track.id)
438
475
  if (!el) continue
439
476
 
440
- const inPt = track.inPoint ?? 0
441
- const trackTime = (playhead - track.start) + inPt
442
- // Derive outPoint from the timeline span — the stored outPoint can drift
443
- // out of sync with start/end during trim operations, causing premature silence.
444
- // The HTML audio element naturally stops at end-of-file, so sourceDuration
445
- // acts as an implicit ceiling without needing an explicit check here.
446
- const outPoint = inPt + (track.end - track.start)
447
- const outsideWindow =
448
- playhead < track.start ||
449
- playhead >= track.end ||
450
- trackTime < 0 ||
451
- trackTime >= outPoint
452
-
453
- if (outsideWindow) {
477
+ const win = audioWindow(track, playhead)
478
+ if (!win.active) {
454
479
  if (!el.paused) el.pause()
455
480
  continue
456
481
  }
457
482
 
458
- if (Math.abs(el.currentTime - trackTime) > 0.3) {
459
- el.currentTime = Math.max(0, trackTime)
483
+ if (Math.abs(el.currentTime - win.trackTime) > 0.3) {
484
+ el.currentTime = Math.max(0, win.trackTime)
460
485
  }
461
486
 
462
487
  if (playing && el.paused) el.play().catch(() => {})
463
488
  if (!playing && !el.paused) el.pause()
464
489
 
465
- // Apply fade-in / fade-out gain envelope
490
+ // `audioWindow.gain` is already `baseVolume * max(0, fadeMul)`.
466
491
  const gain = gainNodesMap.current.get(track.id)
467
- if (gain) {
468
- const fadeIn = track.fadeIn ?? 0
469
- const fadeOut = track.fadeOut ?? 0
470
- const baseVol = track.volume ?? 1
471
- const elapsed = playhead - track.start
472
- const remaining = track.end - playhead
473
-
474
- let fadeMul = 1
475
- if (fadeIn > 0 && elapsed < fadeIn) {
476
- fadeMul = elapsed / fadeIn
477
- }
478
- if (fadeOut > 0 && remaining < fadeOut) {
479
- fadeMul = Math.min(fadeMul, remaining / fadeOut)
480
- }
481
- gain.gain.value = baseVol * Math.max(0, fadeMul)
482
- }
492
+ if (gain) gain.gain.value = win.gain
483
493
  }
484
494
  }, [])
485
495
 
@@ -542,9 +552,17 @@ export function useVideoPlayback(
542
552
  // Only reload if the actual clip sources/trim points changed — not just overlay edits.
543
553
  // Identity includes nobg_preview_src so a bg-removal completing mid-session
544
554
  // (the field appearing on a clip whose src was already loaded) triggers a
545
- // reload to swap the preview to the cutout version.
555
+ // reload to swap the preview to the cutout version. Same reasoning covers
556
+ // proxySrc: an SP3 proxy arriving via SSE mid-session (the field appearing
557
+ // on a clip already loaded from its original src) must also trigger a
558
+ // reload, or the <video> element keeps playing the pre-proxy source until
559
+ // the next unrelated identity change happens to flush it.
560
+ // The proxy component is GATED (SP3 fix B2): an unsupported or
561
+ // failed-this-session proxy contributes '' — so marking a proxy failed
562
+ // (proxyFailTick bump) changes the identity and triggers the reload that
563
+ // swaps the clip back to its master.
546
564
  const identity = clips
547
- .map(c => `${c.nobg_preview_src ?? ''}|${c.src}|${c.inPoint ?? 0}|${c.outPoint ?? ''}`)
565
+ .map(c => `${c.nobg_preview_src ?? ''}|${isProxyUsable(c.proxySrc) ? c.proxySrc : ''}|${c.src}|${c.inPoint ?? 0}|${c.outPoint ?? ''}`)
548
566
  .join(',')
549
567
  if (identity === clipsSourceRef.current) return
550
568
  clipsSourceRef.current = identity
@@ -559,7 +577,7 @@ export function useVideoPlayback(
559
577
  // Clear inactive slot
560
578
  const inactive = getInactiveVideo()
561
579
  if (inactive) { inactive.pause(); inactive.removeAttribute('src') }
562
- }, [clips])
580
+ }, [clips, proxyFailTick])
563
581
 
564
582
  const handlePause = useCallback(() => {
565
583
  // Ignore pause events while the gap clock owns playback state
@@ -580,7 +598,7 @@ export function useVideoPlayback(
580
598
  const elapsed = (performance.now() - gapWallRef.current) / 1000
581
599
  const t = Math.min(gapFromRef.current + elapsed, gapTargetRef.current)
582
600
  lastTimeRef.current = t
583
- onTimeUpdate(t)
601
+ emitTime(t)
584
602
 
585
603
  if (t < gapTargetRef.current) {
586
604
  gapRAFRef.current = requestAnimationFrame(tickGap)
@@ -601,13 +619,13 @@ export function useVideoPlayback(
601
619
  const ns = (1 - activeSlotRef.current) as 0 | 1
602
620
  const nv = ns === 0 ? video0Ref.current : video1Ref.current
603
621
  lastTimeRef.current = nc.start
604
- onTimeUpdate(nc.start)
622
+ emitTime(nc.start)
605
623
  activeIdxRef.current = ni
606
624
  if (nv) {
607
625
  const src = fileUrlRef.current(playbackSrcFor(nc))
608
626
  if (preloadSrcRef.current !== src) { nv.src = src; nv.currentTime = effectiveInPoint(nc) }
609
627
  const gain = ensureVideoGain(ns)
610
- if (gain) gain.gain.value = nc.muted ? 0 : (nc.volume ?? 1)
628
+ if (gain) gain.gain.value = clipGain(videoTrack, nc)
611
629
  playSoon(nv)
612
630
  }
613
631
  void (activeSlotRef.current === 0 ? video0Ref.current : video1Ref.current)?.pause()
@@ -615,7 +633,7 @@ export function useVideoPlayback(
615
633
  setActiveSlot(ns)
616
634
  setShowVideo(true)
617
635
  preloadSrcRef.current = ''
618
- }, [clips, onTimeUpdate])
636
+ }, [clips, videoTrack, emitTime])
619
637
 
620
638
  // Scrub: seek active slot when currentTime jumps externally
621
639
  useEffect(() => {
@@ -725,7 +743,7 @@ export function useVideoPlayback(
725
743
  inactiveVideo.currentTime = effectiveInPoint(clips[nextIdx])
726
744
  const inactiveSlot = (1 - slot) as 0 | 1
727
745
  const nextGain = ensureVideoGain(inactiveSlot)
728
- if (nextGain) nextGain.gain.value = clips[nextIdx].muted ? 0 : (clips[nextIdx].volume ?? 1)
746
+ if (nextGain) nextGain.gain.value = clipGain(videoTrack, clips[nextIdx])
729
747
  }
730
748
  }
731
749
 
@@ -770,7 +788,7 @@ export function useVideoPlayback(
770
788
  const nextVideo = nextSlot === 0 ? video0Ref.current : video1Ref.current
771
789
 
772
790
  lastTimeRef.current = next.start
773
- onTimeUpdate(next.start)
791
+ emitTime(next.start)
774
792
  activeIdxRef.current = nextIdx
775
793
 
776
794
  if (nextVideo) {
@@ -780,7 +798,7 @@ export function useVideoPlayback(
780
798
  nextVideo.currentTime = effectiveInPoint(next)
781
799
  }
782
800
  const nextGain = ensureVideoGain(nextSlot)
783
- if (nextGain) nextGain.gain.value = next.muted ? 0 : (next.volume ?? 1)
801
+ if (nextGain) nextGain.gain.value = clipGain(videoTrack, next)
784
802
  playSoon(nextVideo)
785
803
  }
786
804
 
@@ -794,7 +812,7 @@ export function useVideoPlayback(
794
812
  video.pause()
795
813
  const finalTime = clips[activeIdxRef.current].end
796
814
  lastTimeRef.current = finalTime
797
- onTimeUpdate(finalTime)
815
+ emitTime(finalTime)
798
816
 
799
817
  // If overlays or audio extend beyond the last video clip,
800
818
  // continue advancing time via the gap clock (shows overlays, plays audio).
@@ -818,15 +836,41 @@ export function useVideoPlayback(
818
836
  if (clip.loop && t >= clip.end) {
819
837
  video.pause()
820
838
  lastTimeRef.current = clip.end
821
- onTimeUpdate(clip.end)
839
+ emitTime(clip.end)
822
840
  for (const el of audioRefsMap.current.values()) { if (!el.paused) el.pause() }
823
841
  setIsPlaying(false)
824
842
  return
825
843
  }
826
844
 
827
845
  lastTimeRef.current = t
828
- onTimeUpdate(t)
829
- }, [clips, onTimeUpdate])
846
+ emitTime(t)
847
+ }, [clips, videoTrack, emitTime])
848
+
849
+ /**
850
+ * SP3 fix B2 — decode-failure fallback. Wired to both <video> slots'
851
+ * onError. If the failing element was playing a clip's proxy, suppress that
852
+ * proxy for the session and force a reload (via proxyFailTick → the
853
+ * clip-identity effect), which re-selects src without the proxy tier.
854
+ * Non-proxy errors keep the legacy behavior (logged, nothing else — there
855
+ * is no better source to fall back to).
856
+ */
857
+ const handleVideoError = useCallback((slot: 0 | 1) => {
858
+ const el = (slot === 0 ? video0Ref : video1Ref).current
859
+ const current = el?.currentSrc || el?.src || ''
860
+ // fileUrl() builds `/api/files?path=<encodeURIComponent(abs)>`, so the
861
+ // encoded proxy path appearing in the element's src identifies the proxy
862
+ // as what failed, regardless of URL absolutization.
863
+ const clip = clips.find(c => c.proxySrc && isProxyUsable(c.proxySrc)
864
+ && current.includes(encodeURIComponent(c.proxySrc)))
865
+ if (clip?.proxySrc) {
866
+ console.warn(
867
+ `[montaj] proxy failed to decode — falling back to the master for this session: ${clip.proxySrc}`)
868
+ markProxyFailed(clip.proxySrc)
869
+ setProxyFailTick(t => t + 1)
870
+ return
871
+ }
872
+ console.warn(`[montaj] video error on slot ${slot} (src: ${current || 'none'})`)
873
+ }, [clips])
830
874
 
831
875
  const handleEnded = useCallback(() => {
832
876
  // For looping clips the ended event fires when the source video reaches its natural end.
@@ -926,11 +970,11 @@ export function useVideoPlayback(
926
970
  handleTimeUpdate,
927
971
  handlePause,
928
972
  handleEnded,
973
+ handleVideoError,
929
974
  togglePlay,
930
975
  isCanvasProject,
931
976
  clips,
932
977
  tracks0NonVideo,
933
978
  overlayTracks,
934
- unmutedAudioTracks,
935
979
  }
936
980
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * SDR tone curves: which look an HDR project is tone-mapped through when the
3
+ * render produces a standard-range (SDR) file.
4
+ *
5
+ * Background: an HDR project can export its HDR master untouched, an SDR
6
+ * deliverable, or both. Every SDR deliverable goes through one of Montaj's
7
+ * signature 3D LUTs — the curve decides how highlights land once the extra HDR
8
+ * range is folded down.
9
+ *
10
+ * The preview, the proxies and the editor thumbnails ALWAYS use the default
11
+ * curve. This picker changes the exported file only, which is why choosing a
12
+ * non-default curve swaps the modal's honesty line (see `honestyLine` below).
13
+ *
14
+ * Keep ids in sync with the `curves` keys in montaj_assets/luts/looks.json,
15
+ * which is what the render pipeline validates against.
16
+ */
17
+ export type SdrCurve = 'vivid1' | 'vivid1-neutral'
18
+
19
+ export const DEFAULT_SDR_CURVE: SdrCurve = 'vivid1'
20
+
21
+ export interface SdrCurveInfo {
22
+ id: SdrCurve
23
+ label: string
24
+ /** One-line summary shown under the label. Plain English, no jargon. */
25
+ blurb: string
26
+ }
27
+
28
+ export const SDR_CURVES: SdrCurveInfo[] = [
29
+ {
30
+ id: 'vivid1',
31
+ label: 'Montaj Vivid',
32
+ blurb: 'The signature look. Rich color with highlights rolled off gently, and what your preview shows.',
33
+ },
34
+ {
35
+ id: 'vivid1-neutral',
36
+ label: 'Neutral brights',
37
+ blurb: 'The same color, with flatter bright areas. Calmer on skies, windows and lit skin.',
38
+ },
39
+ ]
40
+
41
+ /** Descriptor for a curve id, or the default curve's when the id is unknown. */
42
+ export function sdrCurveInfo(id: string | undefined): SdrCurveInfo {
43
+ return SDR_CURVES.find(c => c.id === id) ?? SDR_CURVES[0]
44
+ }
45
+
46
+ /**
47
+ * The line under the curve picker. On the default curve it tells the user how
48
+ * each export relates to what they are looking at; on any other curve it
49
+ * becomes a caveat, because the preview is always Montaj Vivid and the export
50
+ * no longer matches it.
51
+ */
52
+ export function honestyLine(curve: string | undefined): string {
53
+ return (curve ?? DEFAULT_SDR_CURVE) === DEFAULT_SDR_CURVE
54
+ ? 'SDR export matches this preview; HDR export adds highlight range on HDR displays.'
55
+ : 'Heads up: this export will not match your preview. The preview always uses Montaj Vivid, so the curve you picked shows up in the exported file only.'
56
+ }