@bycrux/editor 0.11.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Pitch-preserving audio time-stretch — the shared primitive behind every
3
+ * variable-rate playback path (the fast-forward shuttle's audible scrub and
4
+ * per-clip speed).
5
+ *
6
+ * Runs on the MAIN thread, NOT in the AudioWorklet: it is called from the
7
+ * WebCodecs `AudioDecoder`'s `output` callback (which fires on main), so the
8
+ * stretch happens once, inline, as the PCM is produced, and the already-
9
+ * stretched buffer is what gets posted into the ring — the same seam
10
+ * `audio-clock.ts` already resamples at. Keeping the DSP here (and out of the
11
+ * worklet, which stays "thin by architecture" — see `audio-worklet-source.ts`)
12
+ * is what lets it be a pure function under vitest instead of untestable code
13
+ * inlined in a string.
14
+ *
15
+ * The algorithm is WSOLA (Waveform-Similarity Overlap-Add). Cut the input into
16
+ * overlapping Hann-windowed frames and overlap-add them back at a DIFFERENT
17
+ * hop: advance the read head slower than the write head and the signal grows
18
+ * longer; faster and it shrinks. Because each frame keeps its own local
19
+ * waveform, the pitch is untouched — this is the whole reason it is not a
20
+ * resample (`resampleInterleaved` in `audio-clock.ts`), which changes length by
21
+ * changing pitch. The "waveform-similarity" part is a small cross-correlation
22
+ * search that nudges each frame a few samples so successive frames stay phase-
23
+ * aligned across their overlap; that is what stops periodic sounds from beating
24
+ * or clicking and is the difference between WSOLA and plain OLA.
25
+ *
26
+ * This is PREVIEW quality: clearly intelligible, cheap enough to run inline in
27
+ * a decode callback. The final render stretches with ffmpeg's `atempo`
28
+ * elsewhere, not this code, so broadcast-grade transient handling is out of
29
+ * scope here on purpose.
30
+ */
31
+
32
+ // ── Tuning constants ─────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Analysis/synthesis frame length in samples. ~21ms at 48kHz, ~23ms at 44.1kHz
36
+ * — long enough to hold a couple of periods of a low speech/music fundamental
37
+ * (down to ~90Hz), short enough that the local-stationarity assumption OLA
38
+ * rests on holds. Sample-count fixed (the function isn't handed a rate); the
39
+ * 8% span difference between the two target rates is immaterial.
40
+ */
41
+ const FRAME = 1024
42
+
43
+ /**
44
+ * Synthesis hop — the fixed output advance per frame. FRAME/2 (50% overlap) is
45
+ * the COLA sweet spot for a periodic Hann window: two half-overlapped Hann
46
+ * windows sum to exactly 1.0, so the overlap-add has unity gain in the interior
47
+ * with no per-sample renormalization needed there. The realized stretch factor
48
+ * is `SYNTH_HOP / analysisHop`.
49
+ */
50
+ const SYNTH_HOP = FRAME / 2
51
+
52
+ /** Overlap region length (= FRAME − SYNTH_HOP). The span the similarity search matches over. */
53
+ const OVERLAP = FRAME - SYNTH_HOP
54
+
55
+ /**
56
+ * How far, in samples, a frame may be nudged from its ideal read position to
57
+ * find the best waveform continuation. ±128 covers more than one full period of
58
+ * anything above ~190Hz and comfortably locks onto a 440Hz test tone (period
59
+ * ~109 samples), while keeping the correlation search — the hot loop — bounded.
60
+ */
61
+ const SEARCH = 128
62
+
63
+ /**
64
+ * Upper bound on the stretch factor actually applied. Guards against a
65
+ * pathological allocation (a caller passing 1e9 would otherwise ask for a
66
+ * multi-terabyte output); 32× is far beyond any real variable-rate use, where
67
+ * factors live in ~0.25–4.0. The low end needs no clamp — a tiny factor only
68
+ * ever produces a tiny (or empty) output.
69
+ */
70
+ const MAX_STRETCH = 32
71
+
72
+ /** Below this accumulated window weight an output sample got no real coverage → force it to silence. */
73
+ const WEIGHT_EPSILON = 1e-6
74
+
75
+ /** Below this candidate-window energy the correlation is meaningless (silence) → score 0, keep offset 0. */
76
+ const ENERGY_EPSILON = 1e-9
77
+
78
+ /**
79
+ * Time-stretch interleaved PCM by `factor` with pitch preserved.
80
+ * factor = output_duration / input_duration:
81
+ * factor > 1 → longer/slower (e.g. 2.0 doubles length, half speed playback)
82
+ * factor < 1 → shorter/faster (e.g. 0.5 halves length, double speed playback)
83
+ * factor === 1 → identity (return input unchanged or a faithful copy)
84
+ * `input` is interleaved (L,R,L,R,... for stereo) Float32 at any sample rate.
85
+ * Returns a new interleaved Float32Array with ~ (input.length * factor) samples.
86
+ */
87
+ export function timeStretch(input: Float32Array, channels: number, factor: number): Float32Array {
88
+ if (!Number.isFinite(factor) || factor <= 0) {
89
+ throw new Error(`timeStretch: factor must be a finite positive number, got ${factor}`)
90
+ }
91
+ if (!Number.isInteger(channels) || channels < 1) {
92
+ throw new Error(`timeStretch: channels must be a positive integer, got ${channels}`)
93
+ }
94
+ if (input.length === 0) return new Float32Array(0)
95
+ if (factor === 1) return input.slice() // faithful copy — never alias the caller's buffer
96
+
97
+ const f = Math.min(factor, MAX_STRETCH)
98
+ const inFrames = Math.floor(input.length / channels)
99
+ if (inFrames === 0) return new Float32Array(0)
100
+
101
+ // Read head advance per frame. `round` keeps the realized factor
102
+ // (SYNTH_HOP/analysisHop) as close to requested as integer hops allow;
103
+ // `max(1, …)` stops a large factor from rounding the hop to 0 (infinite loop).
104
+ const analysisHop = Math.max(1, Math.round(SYNTH_HOP / f))
105
+ const numFrames = Math.max(1, Math.round(inFrames / analysisHop))
106
+
107
+ // The mono mix drives the ONE similarity decision every channel shares, so L
108
+ // and R are nudged by the same offset and the stereo image is preserved.
109
+ const mono = channels === 1 ? input : downmix(input, channels, inFrames)
110
+ const win = hann(FRAME)
111
+
112
+ // Size for the full overlap-add span, then slice to the exact target length.
113
+ // Normalization (below) makes the slice clean — there is no fade at the cut.
114
+ const outFrames = Math.round(inFrames * f)
115
+ const spanFrames = (numFrames - 1) * SYNTH_HOP + FRAME
116
+ const totalFrames = Math.max(spanFrames, outFrames)
117
+ const out = new Float32Array(totalFrames * channels)
118
+ const weight = new Float32Array(totalFrames)
119
+
120
+ // `target` holds the previous frame's natural continuation (from the mono
121
+ // mix): the waveform the next frame's leading OVERLAP samples should match.
122
+ const target = new Float32Array(OVERLAP)
123
+ let hasTarget = false
124
+
125
+ for (let m = 0; m < numFrames; m++) {
126
+ const idealPos = m * analysisHop
127
+ // First frame is placed as-is; each later one is nudged within ±SEARCH to
128
+ // best continue the previous frame's tail across the overlap region.
129
+ const readPos = hasTarget ? idealPos + bestOffset(mono, inFrames, idealPos, target) : idealPos
130
+ const synthPos = m * SYNTH_HOP
131
+
132
+ for (let ch = 0; ch < channels; ch++) {
133
+ for (let i = 0; i < FRAME; i++) {
134
+ const src = readPos + i
135
+ if (src < 0 || src >= inFrames) continue // reads outside the input are silence
136
+ out[(synthPos + i) * channels + ch] += input[src * channels + ch] * win[i]
137
+ }
138
+ }
139
+ // Window weights are identical for every channel; accumulate once. Out-of-
140
+ // input samples still count (their signal is a real 0), which crossfades the
141
+ // buffer's very edges to silence rather than cutting them hard.
142
+ for (let i = 0; i < FRAME; i++) weight[synthPos + i] += win[i]
143
+
144
+ // The natural continuation of the frame just placed: what a match should
145
+ // look like SYNTH_HOP samples on. Read from the mono mix, zero-padded.
146
+ for (let i = 0; i < OVERLAP; i++) {
147
+ const p = readPos + SYNTH_HOP + i
148
+ target[i] = p >= 0 && p < inFrames ? mono[p] : 0
149
+ }
150
+ hasTarget = true
151
+ }
152
+
153
+ // Normalize by accumulated window weight → unity gain everywhere the windows
154
+ // covered (COLA already gives 1.0 in the interior; this also recovers the
155
+ // first/last samples and absorbs any drift from integer hops). Zero where
156
+ // nothing was written, so silence stays silence and the tail never divides
157
+ // by ~0.
158
+ for (let fr = 0; fr < totalFrames; fr++) {
159
+ const w = weight[fr]
160
+ for (let ch = 0; ch < channels; ch++) {
161
+ out[fr * channels + ch] = w > WEIGHT_EPSILON ? out[fr * channels + ch] / w : 0
162
+ }
163
+ }
164
+
165
+ return out.length === outFrames * channels ? out : out.slice(0, outFrames * channels)
166
+ }
167
+
168
+ // ── Internals ────────────────────────────────────────────────────────────────
169
+
170
+ /** Periodic Hann window (÷N, not ÷(N−1)) — the form whose 50%-overlap copies sum to exactly 1.0. */
171
+ function hann(n: number): Float32Array {
172
+ const w = new Float32Array(n)
173
+ for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / n))
174
+ return w
175
+ }
176
+
177
+ /** Average all channels into one mono track for the shared similarity decision. */
178
+ function downmix(input: Float32Array, channels: number, inFrames: number): Float32Array {
179
+ const mono = new Float32Array(inFrames)
180
+ for (let fr = 0; fr < inFrames; fr++) {
181
+ let s = 0
182
+ for (let ch = 0; ch < channels; ch++) s += input[fr * channels + ch]
183
+ mono[fr] = s / channels
184
+ }
185
+ return mono
186
+ }
187
+
188
+ /**
189
+ * Offset within ±SEARCH of `idealPos` whose OVERLAP-sample window best continues
190
+ * `target`. Ties (and total silence) resolve to 0, so a silent or aperiodic
191
+ * signal degrades gracefully to plain OLA at the ideal hop.
192
+ */
193
+ function bestOffset(mono: Float32Array, inFrames: number, idealPos: number, target: Float32Array): number {
194
+ let bestOff = 0
195
+ let bestScore = correlate(mono, inFrames, idealPos, target)
196
+ for (let off = -SEARCH; off <= SEARCH; off++) {
197
+ if (off === 0) continue
198
+ const score = correlate(mono, inFrames, idealPos + off, target)
199
+ if (score > bestScore) {
200
+ bestScore = score
201
+ bestOff = off
202
+ }
203
+ }
204
+ return bestOff
205
+ }
206
+
207
+ /**
208
+ * Cross-correlation of `mono[pos … pos+OVERLAP)` against `target`, normalized by
209
+ * the candidate window's energy. The target's energy is constant across all
210
+ * offsets in one search, so dropping it from the denominator leaves the argmax
211
+ * unchanged while halving the work; normalizing by the candidate energy is what
212
+ * removes the bias toward louder windows. Out-of-input samples read as 0.
213
+ */
214
+ function correlate(mono: Float32Array, inFrames: number, pos: number, target: Float32Array): number {
215
+ let dot = 0
216
+ let energy = 0
217
+ for (let i = 0; i < OVERLAP; i++) {
218
+ const p = pos + i
219
+ const c = p >= 0 && p < inFrames ? mono[p] : 0
220
+ dot += c * target[i]
221
+ energy += c * c
222
+ }
223
+ return energy <= ENERGY_EPSILON ? 0 : dot / Math.sqrt(energy)
224
+ }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export type {
12
12
  CaptionSegment,
13
13
  Captions,
14
14
  VisualItem,
15
+ VisualTrack,
15
16
  Asset,
16
17
  ImageElement,
17
18
  OverlayElement,
@@ -29,6 +30,8 @@ export type {
29
30
  OverlayFactory,
30
31
  RenderEvent,
31
32
  RenderOptions,
33
+ RenderExport,
34
+ SampleFrameOptions,
32
35
  RenderStatus,
33
36
  RenderPhase,
34
37
  CaptionEvent,
@@ -39,12 +42,27 @@ export type {
39
42
  GlobalOverlayProp,
40
43
  VersionEntry,
41
44
  WaveformChunk,
45
+ PeaksData,
46
+ PeaksResolution,
47
+ GetWaveformPeaksArgs,
48
+ FilmstripSheet,
49
+ FilmstripIndex,
50
+ GetFilmstripArgs,
51
+ AnalyzeAudioPolishArgs,
52
+ AudioPolishAnalysis,
53
+ FootageDropPayload,
54
+ PendingDrop,
55
+ TimelineDropPlacement,
42
56
  EditorAdapter,
57
+ EditorContext,
43
58
  EditorTheme,
44
59
  EditorSlots,
45
60
  CarouselEditorProps,
46
61
  VideoEditorProps,
47
62
  } from './types'
63
+ // `FOOTAGE_DND_MIME` is a value (const), not a type — exported separately so
64
+ // hosts can compare against it when reading a drag event's MIME data.
65
+ export { FOOTAGE_DND_MIME } from './types'
48
66
 
49
67
  // ── Video editor pure helpers ─────────────────────────────────────────────────
50
68
  export {
@@ -52,12 +70,61 @@ export {
52
70
  applyCutToItem,
53
71
  collapseGaps,
54
72
  splitAtTime,
73
+ rippleDelete,
74
+ rollEdit,
75
+ slipItem,
76
+ slideItem,
77
+ setClipSpeed,
78
+ insertClipAt,
79
+ newClipId,
55
80
  } from './video/cuts'
56
81
  export type { Cut } from './video/cuts'
57
82
  export { getOverlayDesignCanvas } from './video/design-canvas'
83
+ // Track-shape tolerance: `project.tracks` may be on disk as the legacy
84
+ // `VisualItem[][]` or as `VisualTrack[]`. Read through `trackItems`; normalize
85
+ // on open with `normalizeTracks` (same object back when already converged).
86
+ export {
87
+ effectiveItemAudio,
88
+ enabledTrackItems,
89
+ enabledTracks,
90
+ mapTrackItems,
91
+ normalizeTracks,
92
+ trackItems,
93
+ withEnabledItemTracks,
94
+ withItemTracks,
95
+ } from './video/timeline/timeline-model'
96
+ // The one placement rule shared by both new-clip drop entry points (the
97
+ // footage-bin drag today, a filesystem drop in a later task) — public so the
98
+ // host can route either drop through it rather than each reimplementing
99
+ // "land where you dropped it, without stomping existing footage".
100
+ export { placeDroppedClip, resolveDropTrackIndex } from './video/timeline/placement'
101
+ export type { DroppedClipPlacement, PlacedClipResult } from './video/timeline/placement'
102
+
103
+ // ── Image tone (HDR image color mapping) ─────────────────────────────────────
104
+ // The picker component is exported so hosts using `onProvideImageTone` can
105
+ // render the same control (variant="header") in their own chrome.
106
+ export { default as ImageToneMenu } from './video/ImageToneMenu'
107
+ export type { ImageToneMenuProps } from './video/ImageToneMenu'
108
+
109
+ // ── Speed control (slider + preset chips) ────────────────────────────────────
110
+ // Shared by the per-clip inspect modal (host `montaj_assets/ui`) and the
111
+ // track-wide settings popover (TrackSettingsPopover.tsx).
112
+ export { default as SpeedControl } from './video/timeline/SpeedControl'
113
+ export type { SpeedControlProps } from './video/timeline/SpeedControl'
114
+ export { default as VolumeControl } from './video/timeline/VolumeControl'
115
+ export type { VolumeControlProps } from './video/timeline/VolumeControl'
116
+ export { IMAGE_TONES, DEFAULT_IMAGE_TONE } from './video/imageTone'
117
+ export type { ImageTone, ImageToneInfo } from './video/imageTone'
118
+
119
+ // ── SDR tone curves (HDR→SDR export look) ────────────────────────────────────
120
+ // Descriptors + the modal's honesty copy. Exported so a host can label its own
121
+ // chrome with the same curve names it sends as `RenderOptions.sdrCurve`.
122
+ export { SDR_CURVES, DEFAULT_SDR_CURVE, sdrCurveInfo, honestyLine } from './video/sdrCurves'
123
+ export type { SdrCurve, SdrCurveInfo } from './video/sdrCurves'
124
+ export type { PreRenderOptions } from './video/RenderModal'
58
125
 
59
126
  // ── Theme ─────────────────────────────────────────────────────────────────────
60
- export { defaultMontajTheme, applyTheme } from './theme'
127
+ export { defaultMontajTheme, lightMontajTheme, applyTheme, isLightTheme } from './theme'
61
128
 
62
129
  // ── State ───────────────────────────────────────────────────────────────────
63
130
  export { useProjectState } from './state/use-project-state'
@@ -116,11 +183,20 @@ export type { OverlayPreviewProps } from './preview/OverlayPreview'
116
183
  export { createPlaybackClock, usePlaybackTime } from './video/playback-clock'
117
184
  export type { PlaybackClock } from './video/playback-clock'
118
185
 
186
+ // ── Source preview (footage-bin → main preview scrub, opt-in) ─────────────────
187
+ // External store a host's footage bin sets to drive a source-scrub overlay on
188
+ // the main preview. Pass the store to both the bin card and VideoEditor's
189
+ // `sourcePreview` prop; absent → the main preview is unchanged. See
190
+ // `video/source-preview.ts`.
191
+ export { createSourcePreviewStore, useSourcePreview } from './video/source-preview'
192
+ export type { SourcePreviewStore, SourcePreviewValue } from './video/source-preview'
193
+
119
194
  // ── Video preview ─────────────────────────────────────────────────────────────
120
195
  export { default as PreviewPlayer } from './video/preview/PreviewPlayer'
121
196
  export { default as CarouselPreview } from './video/preview/CarouselPreview'
122
197
  export { default as OverlayItemsLayer } from './video/preview/OverlayItemsLayer'
123
198
  export { useVideoPlayback } from './video/preview/useVideoPlayback'
199
+ export { useReportContext, REPORT_INTERVAL_MS } from './video/use-report-context'
124
200
  export { useDragOverlay } from './video/preview/useDragOverlay'
125
201
  export type { Corner, DragType } from './video/preview/useDragOverlay'
126
202
 
@@ -142,3 +218,9 @@ export { default as SlideCanvas, resolveAsset } from './carousel/SlideCanvas'
142
218
  export { default as OverlayErrorBoundary } from './carousel/OverlayErrorBoundary'
143
219
  export { default as ReadOnlySlide } from './carousel/ReadOnlySlide'
144
220
  export type { ReadOnlySlideProps } from './carousel/ReadOnlySlide'
221
+
222
+ // ── Footage bin (media panel) ─────────────────────────────────────────────────
223
+ // A host's Footage/B-Roll panel drops this in per source card for the
224
+ // hover-scrub thumbnail (docs/plans/footage-bin-media-panel.md, Phase 2/3).
225
+ export { default as FilmstripScrubber } from './components/FilmstripScrubber'
226
+ export type { FilmstripScrubberProps } from './components/FilmstripScrubber'
@@ -14,44 +14,22 @@
14
14
 
15
15
  import React, { useEffect, useState } from 'react'
16
16
  import type { OverlayFactory } from '../types'
17
+ import { Loader } from '../ui/Loader'
17
18
 
18
19
  // ── Defaults ─────────────────────────────────────────────────────────────────
19
20
 
20
21
  function DefaultSpinner(): React.ReactElement {
21
22
  return (
22
23
  <div
23
- role="status"
24
- aria-label="Loading overlay"
25
24
  style={{
26
25
  position: 'absolute',
27
26
  inset: 0,
28
27
  display: 'flex',
29
28
  alignItems: 'center',
30
29
  justifyContent: 'center',
31
- color: 'rgba(255, 255, 255, 0.65)',
32
30
  }}
33
31
  >
34
- <svg
35
- width="36"
36
- height="36"
37
- viewBox="0 0 24 24"
38
- fill="none"
39
- stroke="currentColor"
40
- strokeWidth="2"
41
- strokeLinecap="round"
42
- >
43
- <g>
44
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
45
- <animateTransform
46
- attributeName="transform"
47
- type="rotate"
48
- from="0 12 12"
49
- to="360 12 12"
50
- dur="0.9s"
51
- repeatCount="indefinite"
52
- />
53
- </g>
54
- </svg>
32
+ <Loader size="sm" />
55
33
  </div>
56
34
  )
57
35
  }
package/src/schema.ts CHANGED
@@ -30,8 +30,14 @@ export interface AudioTrack {
30
30
  }
31
31
  fadeIn?: number // fade-in duration in seconds (0 = no fade)
32
32
  fadeOut?: number // fade-out duration in seconds (0 = no fade)
33
+ // Envelope shape for each fade — see video/timeline/canvas/fade-curve.ts.
34
+ // Absent ⇒ DEFAULT_FADE_CURVE ('exp'), which is also the shape every fade
35
+ // rendered before curves existed, so an un-set project looks unchanged.
36
+ fadeInCurve?: 'linear' | 'log' | 'exp'
37
+ fadeOutCurve?: 'linear' | 'log' | 'exp'
33
38
  sourceDuration?: number // intrinsic duration of the source file in seconds
34
39
  lane?: number // visual grouping — tracks sharing a lane render in the same row
40
+ magnetic?: boolean // when set, this clip's LANE is kept gapless (no gaps/overlaps). Fanned out across the lane like `muted`.
35
41
  }
36
42
 
37
43
  export interface CaptionSegment {
@@ -53,6 +59,24 @@ export interface CaptionSegment {
53
59
  // ffmpeg `drawtext` render branch has no per-segment concept and keeps
54
60
  // reading only the track-level `color`.
55
61
  color?: string
62
+ // Vertical row this segment renders in. Captions in different lanes may be
63
+ // simultaneous and all render, each getting its own row in the timeline and
64
+ // preview. Absent ⇒ lane 0, and NOT auto-incremented.
65
+ //
66
+ // This deliberately diverges from `AudioTrack.lane` / `groupAudioLanes`
67
+ // (timeline/timeline-model.ts), which mint a fresh lane for every lane-less
68
+ // track: every caption segment written before lanes existed is lane-less,
69
+ // so applying that same rule here would explode every existing project's
70
+ // captions into one row per caption on first open, instead of the single
71
+ // row they've always rendered as.
72
+ //
73
+ // Invariant: lanes are dense from 0 (no holes). Read and write this field
74
+ // through `video/captionLanes.ts` rather than touching it directly, so that
75
+ // invariant holds everywhere. Lane is z-order only — a higher lane paints
76
+ // on top; it carries no styling of its own. `Captions.style` and the
77
+ // track-level theme fields (color, fontsize, accentColor, …) stay
78
+ // track-global and do not vary per lane.
79
+ lane?: number
56
80
  }
57
81
 
58
82
  export interface Captions {
@@ -67,12 +91,69 @@ export interface Captions {
67
91
  fontsize?: number
68
92
  bgColor?: string
69
93
  // Per-style accent color fields, each read by the matching JSX caption template.
70
- // A single UI control writes whichever one the active style uses (see TranscriptPanel).
94
+ // A single UI control writes whichever one the active style uses (see CaptionListPanel).
71
95
  accentColor?: string // active-word/box accent — highlight-box, outline
72
96
  highlightColor?: string // active word — karaoke
73
97
  activeColor?: string // active word — pop
74
98
  backgroundColor?: string// text box background — subtitle
75
99
  googleFonts?: string[] // Google Fonts family specs for the caption template (e.g. ["Figtree:wght@700"])
100
+ // Text-styling fields, honoured by the JSX caption templates only (NOT the ffmpeg
101
+ // drawtext render branch). `fontFamily` and `googleFonts` travel together: a family
102
+ // whose font file isn't also fetched via `googleFonts` renders as the fallback face,
103
+ // in both the editor preview and the export.
104
+ fontFamily?: string // CSS font-family stack (e.g. '"Baloo 2", system-ui, sans-serif')
105
+ fontWeight?: number | string // default is per style: clean/karaoke 700, subtitle 600,
106
+ // pop/word-by-word 800, highlight-box/outline 900 — so an
107
+ // existing project with no fontWeight renders unchanged.
108
+ textTransform?: string // 'uppercase' | 'lowercase' | 'capitalize' | 'none'
109
+ letterSpacing?: string // CSS length, e.g. '0.02em'
110
+ lineHeight?: number | string
111
+ textAlign?: string // 'left' | 'center' | 'right'
112
+ }
113
+
114
+ /** Easing applied across the segment LEAVING a keyframe. `hold` is step-end:
115
+ * the value is held until the next keyframe's `t`, then jumps. */
116
+ export type EasingName = 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'hold'
117
+
118
+ /** The transform properties that can be keyframed. Which item KINDS support
119
+ * which of them is decided by `canKeyframe` / `canKeyframeProp`
120
+ * (`montaj_assets/editor/src/video/keyframeOps.ts`) — the runtime source of
121
+ * truth, not this type.
122
+ *
123
+ * `keyframes` is NO LONGER overlay-only (SP9d). Overlays, `image` and
124
+ * `video` items all animate their geometry, in the preview and in the
125
+ * export alike: the renderer compiles each curve into a time-varying ffmpeg
126
+ * filter expression (`render/encode-segment.js`, `animatedGeometry`), which
127
+ * is the per-frame hook the ffmpeg path used to lack.
128
+ *
129
+ * OPACITY ANIMATION is what stayed overlay-only, and that one IS a hard
130
+ * render constraint: ffmpeg applies alpha through `colorchannelmixer`,
131
+ * whose `aa` option is a `<double>` and accepts no expression at all, so a
132
+ * clip cannot be faded through that path in any form. Overlays escape it by
133
+ * being captured frame-by-frame in a browser, where opacity is just CSS.
134
+ * See `canKeyframeProp` for the full reasoning and `docs/RENDER.md` for the
135
+ * measured cost of the alternative. */
136
+ export type KeyframeProp = 'offsetX' | 'offsetY' | 'scale' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity'
137
+
138
+ export interface Keyframe {
139
+ t: number // ITEM-relative seconds — 0 is the item's own `start`, not timeline zero
140
+ value: number
141
+ easing?: EasingName // governs the segment from THIS keyframe to the next; absent = 'linear'
142
+ }
143
+
144
+ /** One animated property. `points` must be ascending by `t` with no duplicates:
145
+ * the read path (`sampleTrack`) assumes it and deliberately does not sort, so
146
+ * the editor normalizes on write.
147
+ *
148
+ * These four types intentionally mirror the canonical definitions in
149
+ * `@bycrux/timeline-core` (`src/curves.js`, declared in its `index.d.ts`),
150
+ * which is where the interpolation actually happens for BOTH the preview and
151
+ * the render. They are re-declared rather than imported because this file is
152
+ * deliberately self-contained (see the header). Keep the two in sync — the
153
+ * shapes are structurally compatible by design. */
154
+ export interface KeyframeTrack {
155
+ prop: KeyframeProp
156
+ points: Keyframe[]
76
157
  }
77
158
 
78
159
  export interface VisualItem {
@@ -82,6 +163,7 @@ export interface VisualItem {
82
163
  start: number
83
164
  end: number
84
165
  sourceDuration?: number // video type only — used for right-edge drag guard
166
+ sourceCreatedAt?: string // video type only — container recording timestamp (ISO 8601); footage-bin "Date created" sort. Absent when the source carried no creation_time tag.
85
167
  inPoint?: number // video type only
86
168
  outPoint?: number // video type only
87
169
  loop?: boolean // video type only — loop source clip within project window
@@ -89,10 +171,17 @@ export interface VisualItem {
89
171
  offsetX?: number
90
172
  offsetY?: number
91
173
  scale?: number
174
+ scaleX?: number // absent = fall back to `scale` (the resolver does `scaleX ?? scale ?? 1`)
175
+ scaleY?: number // absent = fall back to `scale` (the resolver does `scaleY ?? scale ?? 1`)
92
176
  opacity?: number // 0.0–1.0
93
177
  fit?: 'cover' | 'contain' | 'fill' // image type only — how the source fills its box. Default 'cover' (AR-preserving fill+crop). 'contain' letterboxes; 'fill' is legacy stretch (no AR).
94
178
  volume?: number // video audio level 0.0–2.0, default 1.0 (ignored for images)
95
179
  rotation?: number // degrees, clockwise
180
+ /** Which item kinds support this is decided by `canKeyframe`
181
+ * (video/keyframeOps.ts), the runtime source of truth — see `KeyframeProp`
182
+ * above for why it's overlay-only today (a render constraint, not a UI
183
+ * preference). */
184
+ keyframes?: KeyframeTrack[] // animates the transform props over the item's own lifetime. Overlays, images and video clips all honour it; `opacity` animates on overlays ONLY (see KeyframeProp). Absent = a static item, which renders on the unchanged ffmpeg-positioned path.
96
185
  opaque?: boolean // legacy boolean kept for old overlay items
97
186
  props?: Record<string, unknown> // overlay type only
98
187
  googleFonts?: string[] // overlay type only — Google Fonts family specs (e.g. ["Syne:wght@800"])
@@ -102,7 +191,15 @@ export interface VisualItem {
102
191
  normalizedSrc?: string // derived per-window normalized cache; render/preview prefer it; src stays original
103
192
  /** Source-time (original coords) the normalizedSrc cache starts at; the cache covers [normalizedInPoint, normalizedInPoint + duration]. Absent ⇒ assume it starts at the clip's inPoint (legacy rebase-to-0). */
104
193
  normalizedInPoint?: number
194
+ /** Full-source, all-intra 720p H.264+Opus editing proxy for instant-scrub preview (SP3). Never windowed —
195
+ * no `proxyInPoint` — so one proxy can serve every clip sharing a lazy source. Preview-only: render never
196
+ * reads this field. Preview's src precedence, alpha-safe, is
197
+ * `nobg_preview_src ?? proxySrc ?? normalizedSrc ?? src`. Filename carries a look-version tag
198
+ * (`<stem>_proxy_<PROXY_LOOK>.mp4`); bumping `PROXY_LOOK` (e.g. when Montaj Vivid ships) invalidates every
199
+ * existing proxy by construction — the freshness check sees a different filename and regenerates lazily. */
200
+ proxySrc?: string // video type only
105
201
  muted?: boolean // video type only — suppress audio in preview and render
202
+ speed?: number // video type only — playback speed, default 1.0, range 0.25–4
106
203
  sourceCrop?: { x: number; y: number; w: number; h: number } // video type only — non-destructive crop of the source clip (0–1 fractions)
107
204
  sourceWidth?: number // video type only — intrinsic width of the source clip in pixels
108
205
  sourceHeight?: number // video type only — intrinsic height of the source clip in pixels
@@ -141,6 +238,32 @@ export interface VisualItem {
141
238
  text?: string
142
239
  }
143
240
 
241
+ /**
242
+ * One visual track: its items plus the properties that belong to the TRACK
243
+ * rather than to any one clip. Track ORDER is meaningful — index 0 is the
244
+ * primary footage track, higher indices render on top.
245
+ *
246
+ * `volume`, `muted` and `enabled` are optional and ABSENT by default; a track
247
+ * carrying none of them behaves exactly as it did before tracks became
248
+ * objects, so nothing needs to write a default in.
249
+ *
250
+ * A project may still be on disk in the legacy `VisualItem[][]` shape (a bare
251
+ * array of item arrays, with nowhere to put the fields above). Readers tolerate
252
+ * both shapes by going through `trackItems()` (video/timeline/timeline-model)
253
+ * rather than touching `project.tracks` directly; `normalizeTracks()` converts
254
+ * legacy → this shape when a project is opened.
255
+ */
256
+ export interface VisualTrack {
257
+ id: string
258
+ items: VisualItem[]
259
+ /** Gain applied to every item's audio on this track. Absent = unity (1.0). */
260
+ volume?: number
261
+ /** Absent or false = audible. */
262
+ muted?: boolean
263
+ /** Absent or true = the track renders. */
264
+ enabled?: boolean
265
+ }
266
+
144
267
  export interface Asset {
145
268
  id: string
146
269
  src: string
@@ -215,11 +338,31 @@ export interface Slide {
215
338
  export interface EditorProject {
216
339
  id: string
217
340
  status: 'pending' | 'storyboard_ready' | 'draft' | 'final'
218
- settings: { resolution: [number, number]; fps?: number; brandKit?: string; normalize?: 'eager' | 'lazy' }
341
+ settings: {
342
+ resolution: [number, number]
343
+ fps?: number
344
+ brandKit?: string
345
+ normalize?: 'eager' | 'lazy'
346
+ /** Project working color space (e.g. 'sdr_bt709', 'hdr_hlg'). Written by
347
+ * the render pipeline's smart-detect; read here to gate HDR-only UI. */
348
+ colorSpace?: string
349
+ /** Color mapping for overlay images in HDR renders — see video/imageTone.ts.
350
+ * Absent → the render default ('vivid'). No effect on SDR projects. */
351
+ imageTone?: 'vivid' | 'broadcast' | 'punchy' | 'raw'
352
+ /** Which platform's chrome the preview overlays on top of the video — see
353
+ * video/preview/SocialSafeZoneOverlay.tsx. A viewing aid only; never read
354
+ * by render. Absent = no chrome shown (the "None" picker option, and the
355
+ * default for every project until the operator picks a platform). */
356
+ socialPreview?: 'tiktok' | 'youtube' | 'instagram'
357
+ /** Whether dragging the playhead plays audible scrub grains (tape
358
+ * jog-wheel feel) — see engine/scrub-source.ts. Default false (opt-in);
359
+ * an explicit `true` persists the operator's opt-in. */
360
+ audibleScrub?: boolean
361
+ }
219
362
  name?: string | null
220
363
  editingPrompt?: string
221
364
  slides?: Slide[]
222
- tracks?: VisualItem[][]
365
+ tracks?: VisualTrack[]
223
366
  captions?: Captions
224
367
  audio?: { tracks: AudioTrack[] }
225
368
  assets?: Asset[]