@leavepulse/ui 0.80.1 → 0.82.0

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 (236) hide show
  1. package/dist/assets/{LpCommandPalette-C1LRFzbN.css → LpCommandPalette-DCZB4EAf.css} +19 -19
  2. package/dist/assets/LpVideoTrim-Ci6IIHW0.css +8 -0
  3. package/dist/chunks/{ContextMenuItems-DLK4q-mm.js → ContextMenuItems-kRVTuSZd.js} +1 -1
  4. package/dist/chunks/{DropzoneLinkField-CcJu8De3.js → DropzoneLinkField-CvMUu8HQ.js} +1 -1
  5. package/dist/chunks/{FieldLeading-CldGLINn.js → FieldLeading-CNN_5Dp1.js} +1 -1
  6. package/dist/chunks/{FieldWrapper-Cj8ZgoKp.js → FieldWrapper-BBJPEVHr.js} +1 -1
  7. package/dist/chunks/{IconCloseButton-CV4iHRpy.js → IconCloseButton-C59_etp2.js} +1 -1
  8. package/dist/chunks/{LayoutCanvas-BQbq7Ekw.js → LayoutCanvas-BIY74SUe.js} +1 -1
  9. package/dist/chunks/{LayoutNode-BMedRk3g.js → LayoutNode-CTlKlhb-.js} +1 -1
  10. package/dist/chunks/{LpActionBar-C7g52Cyo.js → LpActionBar-Cowb-28D.js} +1 -1
  11. package/dist/chunks/{LpAddressInput-1uakhQk5.js → LpAddressInput-nTJlUQ6K.js} +7 -7
  12. package/dist/chunks/{LpAlert-DWFeR82L.js → LpAlert-DhZdjekN.js} +1 -1
  13. package/dist/chunks/{LpAppShell-InIK01Nn.js → LpAppShell-CK9hTgQ1.js} +5 -5
  14. package/dist/chunks/{LpAutocomplete-DY3LvpB9.js → LpAutocomplete--PaZkQoU.js} +4 -4
  15. package/dist/chunks/{LpAvatar-CEZmMcGh.js → LpAvatar-DVCld_L2.js} +1 -1
  16. package/dist/chunks/{LpBadge-C_VqLHtg.js → LpBadge-DXvpxk6z.js} +1 -1
  17. package/dist/chunks/{LpBottomNav-4I89sPDZ.js → LpBottomNav-COQiBNUN.js} +1 -1
  18. package/dist/chunks/{LpBreadcrumbs-1BBzrfxG.js → LpBreadcrumbs-Dt1O5G4O.js} +1 -1
  19. package/dist/chunks/{LpCalendar-D2dcXjUK.js → LpCalendar-C8Xlukd2.js} +1 -1
  20. package/dist/chunks/{LpCheckbox-CN-MEnbX.js → LpCheckbox-Dpybd3oV.js} +2 -2
  21. package/dist/chunks/{LpCodeBlock-Cj7zk4QE.js → LpCodeBlock-C1Hu5RyT.js} +2 -2
  22. package/dist/chunks/{LpCodeDisplay-COzosm77.js → LpCodeDisplay-BVruT3-A.js} +4 -4
  23. package/dist/chunks/{LpColorPicker-BezW6DX1.js → LpColorPicker-BK-NRaGK.js} +3 -3
  24. package/dist/chunks/{LpConfirmDialog-D9GkDMYr.js → LpConfirmDialog-2CQFKtqU.js} +2 -2
  25. package/dist/chunks/{LpConfirmHost-BH7kf0dm.js → LpConfirmHost-CTkSytVl.js} +1 -1
  26. package/dist/chunks/{LpContextMenu-bn5bc9t7.js → LpContextMenu-DcHPdW-t.js} +2 -2
  27. package/dist/chunks/{LpContextSwitcher-CGr6ZAuq.js → LpContextSwitcher-C_nnDbeI.js} +2 -2
  28. package/dist/chunks/{LpCopyButton-BSr6YaFR.js → LpCopyButton-De7_elaO.js} +1 -1
  29. package/dist/chunks/{LpDataList-Cv7ZUlyy.js → LpDataList-BjZPfQjO.js} +2 -2
  30. package/dist/chunks/{LpDatePicker-CR7vsb5M.js → LpDatePicker-wOrHd0fg.js} +5 -5
  31. package/dist/chunks/{LpDateRangePicker-B9fRO3G6.js → LpDateRangePicker-CVMvvo5t.js} +3 -3
  32. package/dist/chunks/{LpDisclosure-CE4mneaE.js → LpDisclosure-CtQ63LeO.js} +1 -1
  33. package/dist/chunks/{LpDrawer-DU_5MqBz.js → LpDrawer-TZBPzyOM.js} +1 -1
  34. package/dist/chunks/{LpDropdownMenu-_u6GPWhp.js → LpDropdownMenu-DL9LgkS8.js} +2 -2
  35. package/dist/chunks/{LpEditable-DYoMayjt.js → LpEditable-DuPE2tkl.js} +2 -2
  36. package/dist/chunks/{LpEmptyState-DpdHeSfz.js → LpEmptyState-W1U_tKg-.js} +1 -1
  37. package/dist/chunks/{LpFieldNotice-DONFCWV7.js → LpFieldNotice-BLKlYLwu.js} +1 -1
  38. package/dist/chunks/{LpFileTree-f5ktGqsN.js → LpFileTree-CEiRIpj1.js} +4 -4
  39. package/dist/chunks/{LpFileTreeNode-nbp-JwV9.js → LpFileTreeNode-C0292AU2.js} +4 -4
  40. package/dist/chunks/{LpFormField-Bz50_LZW.js → LpFormField-Bhgi0idH.js} +1 -1
  41. package/dist/chunks/{LpHintLayer-B6bA5Cb_.js → LpHintLayer-D8ZKsvsT.js} +1 -1
  42. package/dist/chunks/{LpHints-DyJ5hVcr.js → LpHints-DbIV-wk4.js} +3 -3
  43. package/dist/chunks/{LpIcon-BCQLuHSP.js → LpIcon-CytpcLmS.js} +1 -1
  44. package/dist/chunks/{LpInfraNode-BS1RXYAs.js → LpInfraNode-8cGoY87Q.js} +2 -2
  45. package/dist/chunks/{LpInput-BjbXdjcW.js → LpInput-P2C12X7s.js} +3 -3
  46. package/dist/chunks/{LpInspector-BIyJyM6r.js → LpInspector-DjfMa4tD.js} +4 -4
  47. package/dist/chunks/{LpLightbox-CSAJuUaJ.js → LpLightbox-BEQtCz0m.js} +2 -2
  48. package/dist/chunks/{LpLogInspector-OJEz1vod.js → LpLogInspector-CyxK1d-V.js} +2 -2
  49. package/dist/chunks/{LpLogViewer-CRLwQpk0.js → LpLogViewer-CZrc_HG7.js} +6 -6
  50. package/dist/chunks/{LpMapPicker-DW2EnrCJ.js → LpMapPicker-CbTfFcEu.js} +1 -1
  51. package/dist/chunks/{LpMediaGallery-BNEYctDP.js → LpMediaGallery-C_SwNR4y.js} +3 -3
  52. package/dist/chunks/{LpModal-DdTqdf4x.js → LpModal-B6Yt0G7E.js} +1 -1
  53. package/dist/chunks/{LpModalNav-BL7Dp0om.js → LpModalNav-CMYWEiUs.js} +3 -3
  54. package/dist/chunks/{LpNotificationBell-d0F6MWxD.js → LpNotificationBell-BJKPx0X1.js} +4 -4
  55. package/dist/chunks/{LpNotificationFeed-BIe5lapb.js → LpNotificationFeed-orWISVez.js} +4 -4
  56. package/dist/chunks/{LpNumberField-D5Dx-hzD.js → LpNumberField-C5MeLsoE.js} +3 -3
  57. package/dist/chunks/{LpOtpInput-BVl74Lfl.js → LpOtpInput-6SCQ64uK.js} +1 -1
  58. package/dist/chunks/{LpPageScrollbar-D2O0xhJw.js → LpPageScrollbar-C9sTeU-2.js} +3 -8
  59. package/dist/chunks/{LpPagination-D0wb-Uru.js → LpPagination-CkwbdFa9.js} +1 -1
  60. package/dist/chunks/{LpPaletteTheme-DdilCBXA.js → LpPaletteTheme-RWgbWFOz.js} +2 -2
  61. package/dist/chunks/{LpPasswordInput-DUtqWMkA.js → LpPasswordInput-DJBRo5lt.js} +2 -2
  62. package/dist/chunks/{LpPhoneInput-CalRjkqc.js → LpPhoneInput-DcmcJwos.js} +3 -3
  63. package/dist/chunks/{LpPie-BVAEmVBT.js → LpPie-DmMlhgo2.js} +2 -2
  64. package/dist/chunks/{LpPipeline-Dk7_sRP1.js → LpPipeline-BXcp2shV.js} +2 -2
  65. package/dist/chunks/{LpQuota-DKx0IZYA.js → LpQuota-CtEiqRg4.js} +1 -1
  66. package/dist/chunks/{LpRadioGroup-E3SP6j9h.js → LpRadioGroup-Ubkk_eSq.js} +2 -2
  67. package/dist/chunks/{LpRangeCalendar-BFJFaen4.js → LpRangeCalendar-DCswVYbU.js} +1 -1
  68. package/dist/chunks/{LpRangeSlider-b6ipywRB.js → LpRangeSlider-DM1qy5Tp.js} +1 -1
  69. package/dist/chunks/{LpRepeater-BLrw62Bh.js → LpRepeater-BMo2Eabn.js} +2 -2
  70. package/dist/chunks/{LpSecretField-BV8BNjKc.js → LpSecretField-SSRGVqRc.js} +5 -5
  71. package/dist/chunks/{LpSection-CghNNxIv.js → LpSection-C3bTxD7t.js} +1 -1
  72. package/dist/chunks/{LpSegmented-DjvrHL_G.js → LpSegmented-C1DF6m6A.js} +2 -2
  73. package/dist/chunks/{LpSelect-DYPimzAZ.js → LpSelect-DvDr1VPn.js} +4 -4
  74. package/dist/chunks/{LpSidebar-D3S8rYFf.js → LpSidebar-Bql881r6.js} +4 -4
  75. package/dist/chunks/{LpSidebarNav-Dlse8vxT.js → LpSidebarNav-CKx2KJvr.js} +3 -3
  76. package/dist/chunks/{LpSidebarRow-Cqd_6tGG.js → LpSidebarRow-BMOL9kOG.js} +3 -3
  77. package/dist/chunks/{LpSlider-B9XIC75O.js → LpSlider-DsSMe4b5.js} +1 -1
  78. package/dist/chunks/{LpStat-kyLRmX-m.js → LpStat-CQYYDMS6.js} +2 -2
  79. package/dist/chunks/{LpStatus-szB2dWzM.js → LpStatus-g5nSJfU3.js} +1 -1
  80. package/dist/chunks/{LpStepMark-DP3CDSm9.js → LpStepMark-seVNOuNJ.js} +1 -1
  81. package/dist/chunks/{LpStepper-BS3iaJIz.js → LpStepper-D6qEXihZ.js} +1 -1
  82. package/dist/chunks/{LpSwitch-DRFy8RNr.js → LpSwitch-DM5CVm31.js} +1 -1
  83. package/dist/chunks/{LpTabFace-C9-OperL.js → LpTabFace-ZWwelqRU.js} +2 -2
  84. package/dist/chunks/{LpTableFilter-CEIwLupk.js → LpTableFilter-CWU9Dmk-.js} +2 -2
  85. package/dist/chunks/{LpTagsInput-ClDeF_aU.js → LpTagsInput-C2qVF2ax.js} +5 -5
  86. package/dist/chunks/{LpTextarea-DEY6pUBy.js → LpTextarea-C3EoV_xv.js} +1 -1
  87. package/dist/chunks/{LpThemeStudio-WY72Z1Db.js → LpThemeStudio-BIBahaIs.js} +5 -5
  88. package/dist/chunks/{LpThemeSwitcher-9uCH9c-K.js → LpThemeSwitcher-D4MqFLGS.js} +3 -3
  89. package/dist/chunks/{LpTimeField-BmgxCzQO.js → LpTimeField-DPOkbvyP.js} +2 -2
  90. package/dist/chunks/{LpToaster-DLKO-efn.js → LpToaster-Dj1VKxFv.js} +2 -2
  91. package/dist/chunks/{LpTopologyCanvas-B-PhPa7k.js → LpTopologyCanvas-Bspzwm7x.js} +4 -4
  92. package/dist/chunks/{LpTour-ODlBcat2.js → LpTour-CwpVdVN-.js} +1 -1
  93. package/dist/chunks/{LpTruncate--nhT0iUo.js → LpTruncate-JSaIW25q.js} +1 -1
  94. package/dist/chunks/{LpWindow-DJxE8Zl-.js → LpWindow-RVcP436Z.js} +2 -2
  95. package/dist/chunks/{LpWorkspaceSwitcher-Bpf41Bw5.js → LpWorkspaceSwitcher-D0WOz1Fg.js} +3 -3
  96. package/dist/chunks/{MenuTitle-CMjvLsvC.js → MenuTitle-D8Umab23.js} +1 -1
  97. package/dist/chunks/{OptionContent-DY8NaG8s.js → OptionContent-DCnX_KRK.js} +1 -1
  98. package/dist/chunks/{PickerField-CgfkaBgg.js → PickerField-ChjY27bm.js} +1 -1
  99. package/dist/chunks/{RevealToggle-CPMXxaKv.js → RevealToggle-MaL7PEjr.js} +1 -1
  100. package/dist/chunks/{TourCard-D4b3lMHi.js → TourCard-oXZSpHKb.js} +1 -1
  101. package/dist/chunks/{icons-C1vppR4f.js → icons-BOdqDUii.js} +1 -1
  102. package/dist/component-names.d.ts +1 -1
  103. package/dist/components/ContextMenuItems.vue.js +1 -1
  104. package/dist/components/DropzoneLinkField.vue.js +1 -1
  105. package/dist/components/FieldLeading.vue.js +1 -1
  106. package/dist/components/FieldWrapper.vue.js +1 -1
  107. package/dist/components/IconCloseButton.vue.js +1 -1
  108. package/dist/components/LayoutCanvas.vue.js +1 -1
  109. package/dist/components/LayoutNode.vue.js +1 -1
  110. package/dist/components/LpActionBar.vue.js +1 -1
  111. package/dist/components/LpAddressInput.vue.js +1 -1
  112. package/dist/components/LpAlert.vue.js +1 -1
  113. package/dist/components/LpAppShell.vue.js +1 -1
  114. package/dist/components/LpAutocomplete.vue.js +1 -1
  115. package/dist/components/LpAvatar.vue.js +1 -1
  116. package/dist/components/LpBadge.vue.js +1 -1
  117. package/dist/components/LpBottomNav.vue.js +1 -1
  118. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  119. package/dist/components/LpCalendar.vue.js +1 -1
  120. package/dist/components/LpChart.vue.js +2 -2
  121. package/dist/components/LpCheckbox.vue.js +1 -1
  122. package/dist/components/LpCodeBlock.vue.js +1 -1
  123. package/dist/components/LpCodeDisplay.vue.js +1 -1
  124. package/dist/components/LpColorPicker.vue.js +1 -1
  125. package/dist/components/LpCommandPalette.vue.js +6 -5
  126. package/dist/components/LpConfirmDialog.vue.js +1 -1
  127. package/dist/components/LpConfirmHost.vue.js +1 -1
  128. package/dist/components/LpContextMenu.vue.js +1 -1
  129. package/dist/components/LpContextSwitcher.vue.js +1 -1
  130. package/dist/components/LpCopyButton.vue.js +1 -1
  131. package/dist/components/LpDataList.vue.js +1 -1
  132. package/dist/components/LpDatePicker.vue.js +1 -1
  133. package/dist/components/LpDateRangePicker.vue.js +1 -1
  134. package/dist/components/LpDisclosure.vue.js +1 -1
  135. package/dist/components/LpDock.vue.js +1 -1
  136. package/dist/components/LpDrawer.vue.js +1 -1
  137. package/dist/components/LpDropdownMenu.vue.js +1 -1
  138. package/dist/components/LpDropzone.vue.js +2 -2
  139. package/dist/components/LpEditable.vue.js +1 -1
  140. package/dist/components/LpEmptyState.vue.js +1 -1
  141. package/dist/components/LpFieldNotice.vue.js +1 -1
  142. package/dist/components/LpFileTree.vue.js +1 -1
  143. package/dist/components/LpFileTreeNode.vue.js +1 -1
  144. package/dist/components/LpFormField.vue.js +1 -1
  145. package/dist/components/LpHintLayer.vue.js +1 -1
  146. package/dist/components/LpHints.vue.js +1 -1
  147. package/dist/components/LpIcon.vue.js +1 -1
  148. package/dist/components/LpImageEditor.vue.js +1 -1
  149. package/dist/components/LpInfraNode.vue.js +1 -1
  150. package/dist/components/LpInput.vue.js +1 -1
  151. package/dist/components/LpInspector.vue.js +1 -1
  152. package/dist/components/LpLightbox.vue.js +1 -1
  153. package/dist/components/LpLogInspector.vue.js +1 -1
  154. package/dist/components/LpLogViewer.vue.js +1 -1
  155. package/dist/components/LpMapPicker.vue.js +1 -1
  156. package/dist/components/LpMediaGallery.vue.js +1 -1
  157. package/dist/components/LpModal.vue.js +1 -1
  158. package/dist/components/LpModalNav.vue.js +1 -1
  159. package/dist/components/LpNotificationBell.vue.js +1 -1
  160. package/dist/components/LpNotificationFeed.vue.js +1 -1
  161. package/dist/components/LpNumberField.vue.js +1 -1
  162. package/dist/components/LpOtpInput.vue.js +1 -1
  163. package/dist/components/LpPageScrollbar.vue.js +1 -1
  164. package/dist/components/LpPagination.vue.js +1 -1
  165. package/dist/components/LpPaletteFacts.vue.js +1 -1
  166. package/dist/components/LpPaletteHeader.vue.js +2 -2
  167. package/dist/components/LpPaletteTheme.vue.js +1 -1
  168. package/dist/components/LpPasswordInput.vue.js +1 -1
  169. package/dist/components/LpPhoneInput.vue.js +1 -1
  170. package/dist/components/LpPie.vue.js +1 -1
  171. package/dist/components/LpPipeline.vue.js +1 -1
  172. package/dist/components/LpQuota.vue.js +1 -1
  173. package/dist/components/LpRadioGroup.vue.js +1 -1
  174. package/dist/components/LpRangeCalendar.vue.js +1 -1
  175. package/dist/components/LpRangeSlider.vue.js +1 -1
  176. package/dist/components/LpRepeater.vue.js +1 -1
  177. package/dist/components/LpSecretField.vue.js +1 -1
  178. package/dist/components/LpSection.vue.js +1 -1
  179. package/dist/components/LpSegmented.vue.js +1 -1
  180. package/dist/components/LpSelect.vue.js +1 -1
  181. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  182. package/dist/components/LpSidebar.vue.js +1 -1
  183. package/dist/components/LpSidebarNav.vue.js +1 -1
  184. package/dist/components/LpSidebarRow.vue.js +1 -1
  185. package/dist/components/LpSlider.vue.js +1 -1
  186. package/dist/components/LpStat.vue.js +1 -1
  187. package/dist/components/LpStatus.vue.js +1 -1
  188. package/dist/components/LpStepMark.vue.js +1 -1
  189. package/dist/components/LpStepper.vue.js +1 -1
  190. package/dist/components/LpSwitch.vue.js +1 -1
  191. package/dist/components/LpTabFace.vue.js +1 -1
  192. package/dist/components/LpTable.vue.js +9 -9
  193. package/dist/components/LpTableFilter.vue.js +1 -1
  194. package/dist/components/LpTabs.vue.js +2 -2
  195. package/dist/components/LpTagsInput.vue.js +1 -1
  196. package/dist/components/LpTextarea.vue.js +1 -1
  197. package/dist/components/LpThemeStudio.vue.js +1 -1
  198. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  199. package/dist/components/LpTimeField.vue.js +1 -1
  200. package/dist/components/LpToaster.vue.js +1 -1
  201. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  202. package/dist/components/LpTour.vue.js +1 -1
  203. package/dist/components/LpTruncate.vue.js +1 -1
  204. package/dist/components/LpVideoTrim.vue.d.ts +68 -0
  205. package/dist/components/LpVideoTrim.vue.js +955 -0
  206. package/dist/components/LpWindow.vue.js +1 -1
  207. package/dist/components/LpWorkspaceSwitcher.vue.js +1 -1
  208. package/dist/components/MenuTitle.vue.js +1 -1
  209. package/dist/components/OptionContent.vue.js +1 -1
  210. package/dist/components/PickerField.vue.js +1 -1
  211. package/dist/components/RevealToggle.vue.js +1 -1
  212. package/dist/components/TourCard.vue.js +1 -1
  213. package/dist/components/chartWindow.js +4 -8
  214. package/dist/components/menuWhen.js +1 -1
  215. package/dist/components/videoFrames.d.ts +37 -0
  216. package/dist/components/videoFrames.js +134 -0
  217. package/dist/components/videoTrim.d.ts +75 -0
  218. package/dist/components/videoTrim.js +127 -0
  219. package/dist/composables/overlayHistory.js +16 -2
  220. package/dist/composables/span.d.ts +10 -0
  221. package/dist/composables/span.js +15 -0
  222. package/dist/icons-all.js +1 -1
  223. package/dist/index.d.ts +3 -0
  224. package/dist/index.js +96 -89
  225. package/package.json +1 -1
  226. package/src/component-names.ts +1 -0
  227. package/src/components/LpCommandPalette.vue +5 -0
  228. package/src/components/LpPageScrollbar.vue +13 -11
  229. package/src/components/LpVideoTrim.vue +924 -0
  230. package/src/components/chartWindow.ts +4 -8
  231. package/src/components/videoFrames.ts +171 -0
  232. package/src/components/videoTrim.ts +198 -0
  233. package/src/composables/overlayHistory.ts +29 -2
  234. package/src/composables/span.ts +21 -0
  235. package/src/index.ts +3 -0
  236. package/src/kit-icons.ts +1 -1
@@ -1,4 +1,5 @@
1
1
  import { clamp } from "../composables/clamp"
2
+ import { shiftSpan, zoomSpan } from "../composables/span"
2
3
 
3
4
  /*
4
5
  * The part of a long series a chart draws: which points are in the window, and
@@ -54,18 +55,13 @@ export function clampRange(range: ChartRange | null | undefined, count: number):
54
55
  /** The range after a zoom by `factor` (below 1 is in), keeping `anchor` (0–1 across it) in place. */
55
56
  export function zoomRange(range: ChartRange | null, count: number, anchor: number, factor: number): ChartRange | null {
56
57
  const from = range ?? { start: 0, end: count - 1 }
57
- const span = from.end - from.start
58
- const next = Math.max(MIN_RANGE_POINTS - 1, span * factor)
59
- const at = from.start + span * clamp(anchor, 0, 1)
60
- const start = at - (at - from.start) * (next / (span || 1))
61
- return clampRange({ start, end: start + next }, count)
58
+ const at = from.start + (from.end - from.start) * clamp(anchor, 0, 1)
59
+ return clampRange(zoomSpan(from.start, from.end, at, factor, MIN_RANGE_POINTS - 1), count)
62
60
  }
63
61
 
64
62
  /** The range moved by `delta` points, keeping its width at the ends of the data. */
65
63
  export function panRange(range: ChartRange, count: number, delta: number): ChartRange | null {
66
- const span = range.end - range.start
67
- const start = Math.max(0, Math.min(count - 1 - span, range.start + delta))
68
- return clampRange({ start, end: start + span }, count)
64
+ return clampRange(shiftSpan(range.start, range.end, delta, 0, count - 1), count)
69
65
  }
70
66
 
71
67
  /**
@@ -0,0 +1,171 @@
1
+ /*
2
+ * Stills for LpVideoTrim's timeline, taken from a second, hidden <video> of the
3
+ * same address: seek, wait for the frame, draw it on a small canvas.
4
+ *
5
+ * - One seek at a time. A seek started while another runs is dropped by some
6
+ * engines and freezes WebKitGTK's GStreamer pipeline (WebKit bug 194499);
7
+ * a seek that never answers is given up after a while, so one bad spot does
8
+ * not stop the strip.
9
+ * - A seek lands on a key frame where the engine can (`fastSeek`, WebKit and
10
+ * Firefox): a thumbnail needs a nearby frame, not the exact one, and a key
11
+ * frame needs no decoding forward from the previous one.
12
+ * - Asking again replaces what is still queued: zooming or panning should not
13
+ * wait for stills of a stretch already scrolled away.
14
+ * - The canvas is only ever drawn, never read back, so a video from another
15
+ * origin without CORS still gives stills (a tainted canvas still shows).
16
+ */
17
+
18
+ const SEEK_TIMEOUT_MS = 4000
19
+ const FRAME_TIMEOUT_MS = 250
20
+ const CACHE_LIMIT = 240
21
+
22
+ export interface FrameRequest {
23
+ key: string
24
+ time: number
25
+ }
26
+
27
+ type VideoWithFrames = HTMLVideoElement & {
28
+ requestVideoFrameCallback?: (cb: () => void) => number
29
+ fastSeek?: (time: number) => void
30
+ }
31
+
32
+ export class FrameSampler {
33
+ private queue: FrameRequest[] = []
34
+ private running = false
35
+ private disposed = false
36
+ private cache = new Map<string, HTMLCanvasElement>()
37
+
38
+ constructor(
39
+ private readonly video: VideoWithFrames,
40
+ private readonly size: () => { width: number; height: number },
41
+ private readonly onFrame: (key: string, canvas: HTMLCanvasElement) => void,
42
+ ) {}
43
+
44
+ /** A frame taken earlier, if it is still kept. */
45
+ cached(key: string): HTMLCanvasElement | undefined {
46
+ const hit = this.cache.get(key)
47
+ if (hit) {
48
+ // Least recently used goes first: refresh this one's place.
49
+ this.cache.delete(key)
50
+ this.cache.set(key, hit)
51
+ }
52
+ return hit
53
+ }
54
+
55
+ /** The frames wanted now, most wanted first; whatever was queued before is dropped. */
56
+ want(requests: FrameRequest[]): void {
57
+ this.queue = requests.filter((r) => !this.cache.has(r.key))
58
+ void this.pump()
59
+ }
60
+
61
+ dispose(): void {
62
+ this.disposed = true
63
+ this.queue = []
64
+ this.cache.clear()
65
+ }
66
+
67
+ private async pump() {
68
+ if (this.running) return
69
+ this.running = true
70
+ try {
71
+ while (!this.disposed && this.queue.length) {
72
+ const next = this.queue.shift()!
73
+ if (this.cache.has(next.key)) continue
74
+ const canvas = await this.take(next.time)
75
+ if (this.disposed) return
76
+ if (!canvas) continue
77
+ this.cache.set(next.key, canvas)
78
+ if (this.cache.size > CACHE_LIMIT) this.cache.delete(this.cache.keys().next().value as string)
79
+ this.onFrame(next.key, canvas)
80
+ }
81
+ } finally {
82
+ this.running = false
83
+ }
84
+ }
85
+
86
+ private async take(time: number): Promise<HTMLCanvasElement | null> {
87
+ const video = this.video
88
+ if (video.readyState < 1) {
89
+ const ready = await this.once("loadedmetadata", SEEK_TIMEOUT_MS)
90
+ if (!ready) return null
91
+ }
92
+ const target = Math.min(Math.max(0, time), Number.isFinite(video.duration) ? Math.max(0, video.duration - 0.05) : time)
93
+ const seeked = this.once("seeked", SEEK_TIMEOUT_MS)
94
+ if (video.fastSeek) video.fastSeek(target)
95
+ else video.currentTime = target
96
+ if (!(await seeked)) return null
97
+ await this.framePresented()
98
+ if (this.disposed || !video.videoWidth) return null
99
+ const { width, height } = this.size()
100
+ const ratio = Math.min(2, video.ownerDocument.defaultView?.devicePixelRatio ?? 1)
101
+ const canvas = video.ownerDocument.createElement("canvas")
102
+ canvas.width = Math.max(1, Math.round(width * ratio))
103
+ canvas.height = Math.max(1, Math.round(height * ratio))
104
+ const ctx = canvas.getContext("2d")
105
+ if (!ctx) return null
106
+ // Cover the tile, as object-fit: cover would: a strip of letterboxes reads as broken.
107
+ const scale = Math.max(canvas.width / video.videoWidth, canvas.height / video.videoHeight)
108
+ const w = video.videoWidth * scale
109
+ const h = video.videoHeight * scale
110
+ try {
111
+ ctx.drawImage(video, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h)
112
+ } catch {
113
+ return null
114
+ }
115
+ return canvas
116
+ }
117
+
118
+ /*
119
+ * `seeked` says the position moved, not that its picture is ready to draw:
120
+ * Chromium can still hand the previous frame for a moment. Wait for the
121
+ * frame where the engine reports it, and a couple of paints where not.
122
+ */
123
+ private framePresented(): Promise<void> {
124
+ const video = this.video
125
+ const win = video.ownerDocument.defaultView
126
+ return new Promise((resolve) => {
127
+ let done = false
128
+ const finish = () => {
129
+ if (done) return
130
+ done = true
131
+ resolve()
132
+ }
133
+ if (video.requestVideoFrameCallback) {
134
+ video.requestVideoFrameCallback(finish)
135
+ win?.setTimeout(finish, FRAME_TIMEOUT_MS)
136
+ } else if (win) {
137
+ win.requestAnimationFrame(() => win.requestAnimationFrame(finish))
138
+ } else finish()
139
+ })
140
+ }
141
+
142
+ private once(event: string, timeout: number): Promise<boolean> {
143
+ const video = this.video
144
+ const win = video.ownerDocument.defaultView
145
+ return new Promise((resolve) => {
146
+ const finish = (ok: boolean) => {
147
+ video.removeEventListener(event, onEvent)
148
+ video.removeEventListener("error", onError)
149
+ if (timer) win?.clearTimeout(timer)
150
+ resolve(ok)
151
+ }
152
+ const onEvent = () => finish(true)
153
+ const onError = () => finish(false)
154
+ video.addEventListener(event, onEvent)
155
+ video.addEventListener("error", onError)
156
+ const timer = win?.setTimeout(() => finish(false), timeout)
157
+ })
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Seconds one still stands for at this zoom: the shortest power of two that
163
+ * keeps a still at least `minPx` wide. Stills sit at fixed points in time, so
164
+ * panning reuses the ones already taken and a zoom step of two keeps half.
165
+ */
166
+ export function stillSpan(secondsPerPx: number, minPx: number): number {
167
+ const wanted = secondsPerPx * minPx
168
+ let span = 1 / 8
169
+ while (span < wanted && span < 8192) span *= 2
170
+ return span
171
+ }
@@ -0,0 +1,198 @@
1
+ /*
2
+ * The arithmetic behind LpVideoTrim, kept apart from the component so the
3
+ * rules a server will rely on (what range comes out, what a typed time means)
4
+ * are tested without a video.
5
+ */
6
+ import { clamp } from "../composables/clamp"
7
+ import { shiftSpan, zoomSpan } from "../composables/span"
8
+
9
+ export type TimeRange = [number, number]
10
+
11
+ /** A rectangle on the video's frame, every value a fraction of it (0..1). */
12
+ export interface FrameRegion {
13
+ x: number
14
+ y: number
15
+ w: number
16
+ h: number
17
+ }
18
+
19
+ export interface RangeLimits {
20
+ duration: number
21
+ /** Shortest clip, seconds. */
22
+ minClip: number
23
+ /** Longest clip, seconds; Infinity for none. */
24
+ maxClip: number
25
+ }
26
+
27
+ /* Milliseconds: finer than any frame, and what an ffmpeg -ss argument takes. */
28
+ const round = (t: number) => Math.round(t * 1000) / 1000
29
+
30
+ /**
31
+ * A typed time, the inverse of `formatClipTime`: "75", "75.5", "1:15",
32
+ * "75:00.5", and "1:02:03.25" for whoever types the hours. A comma reads as
33
+ * the decimal point, as a Russian keyboard's numpad types it. Anything else,
34
+ * a negative or a seconds field past 59 after a colon, is null.
35
+ */
36
+ export function parseClipTime(text: string): number | null {
37
+ const value = text.trim().replace(",", ".")
38
+ if (!/^\d+(:\d{1,2}){0,2}(\.\d*)?$/.test(value)) return null
39
+ const parts = value.split(":")
40
+ let total = 0
41
+ for (const [i, part] of parts.entries()) {
42
+ const n = Number(part)
43
+ if (!Number.isFinite(n)) return null
44
+ if (i > 0 && n >= 60) return null
45
+ total = total * 60 + n
46
+ }
47
+ return total
48
+ }
49
+
50
+ function limitsOf(l: RangeLimits) {
51
+ const duration = Math.max(0, l.duration)
52
+ const min = clamp(l.minClip, 0, duration)
53
+ const max = Math.max(min, l.maxClip)
54
+ return { duration, min, max }
55
+ }
56
+
57
+ /** A range that keeps every limit: inside the video, no shorter than minClip, no longer than maxClip. */
58
+ export function normalizeRange(range: TimeRange | null | undefined, limits: RangeLimits): TimeRange {
59
+ const { duration, min, max } = limitsOf(limits)
60
+ if (!range || !Number.isFinite(range[0]) || !Number.isFinite(range[1])) return [0, round(Math.min(duration, max))]
61
+ let [a, b] = range[0] <= range[1] ? range : [range[1], range[0]]
62
+ a = clamp(a, 0, Math.max(0, duration - min))
63
+ b = clamp(b, a + min, Math.min(duration, a + max))
64
+ return [round(a), round(b)]
65
+ }
66
+
67
+ /**
68
+ * One end dragged to `t`. The dragged end stops at a limit and the other end
69
+ * stays put: a handle that pushed its partner would move the clip's other
70
+ * edge without the person touching it.
71
+ */
72
+ export function dragEnd(range: TimeRange, end: 0 | 1, t: number, limits: RangeLimits): TimeRange {
73
+ const { duration, min, max } = limitsOf(limits)
74
+ const [a, b] = range
75
+ if (end === 0) return [round(clamp(t, Math.max(0, b - max), b - min)), b]
76
+ return [a, round(clamp(t, a + min, Math.min(duration, a + max)))]
77
+ }
78
+
79
+ /**
80
+ * One end set to `t` on purpose (the I or O key, a typed time). That end goes
81
+ * where it was told, and the other follows when a limit asks for it.
82
+ */
83
+ export function setEnd(range: TimeRange, end: 0 | 1, t: number, limits: RangeLimits): TimeRange {
84
+ const { duration, min, max } = limitsOf(limits)
85
+ const [a, b] = range
86
+ if (end === 0) {
87
+ const start = clamp(t, 0, duration - min)
88
+ return [round(start), round(clamp(b, start + min, Math.min(duration, start + max)))]
89
+ }
90
+ const stop = clamp(t, min, duration)
91
+ return [round(clamp(a, Math.max(0, stop - max), stop - min)), round(stop)]
92
+ }
93
+
94
+ export interface TimeView {
95
+ start: number
96
+ end: number
97
+ }
98
+
99
+ /** The view zoomed by `factor` (above 1 zooms in) around `anchor`, which stays where it is on screen. */
100
+ export function zoomView(view: TimeView, anchor: number, factor: number, duration: number, minSpan: number): TimeView {
101
+ const span = view.end - view.start
102
+ // No wider than the video: the shared zoom only floors the width, so cap the factor here.
103
+ const next = zoomSpan(view.start, view.end, anchor, Math.min(1 / factor, duration / (span || 1)), Math.min(minSpan, duration))
104
+ return shiftView(next, 0, duration)
105
+ }
106
+
107
+ /** The view moved by `delta` seconds, held inside the video. */
108
+ export function shiftView(view: TimeView, delta: number, duration: number): TimeView {
109
+ const span = Math.min(view.end - view.start, duration)
110
+ return shiftSpan(view.start, view.start + span, delta, 0, duration)
111
+ }
112
+
113
+ /** The view moved just enough to show `t`, with `margin` of its span to spare. */
114
+ export function revealTime(view: TimeView, t: number, duration: number, margin = 0.1): TimeView {
115
+ const span = view.end - view.start
116
+ const pad = span * margin
117
+ if (t < view.start + pad) return shiftView(view, t - pad - view.start, duration)
118
+ if (t > view.end - pad) return shiftView(view, t + pad - view.end, duration)
119
+ return view
120
+ }
121
+
122
+ const TICK_STEPS = [0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200]
123
+
124
+ /** Seconds between ruler marks, so that marks stand at least `minPx` apart. */
125
+ export function tickStep(span: number, width: number, minPx = 72): number {
126
+ if (span <= 0 || width <= 0) return 1
127
+ const perPx = span / width
128
+ return TICK_STEPS.find((step) => step / perPx >= minPx) ?? TICK_STEPS[TICK_STEPS.length - 1]!
129
+ }
130
+
131
+ /** The ruler's marks within the view. */
132
+ export function ticks(view: TimeView, step: number): number[] {
133
+ const out: number[] = []
134
+ const first = Math.ceil(view.start / step - 1e-9) * step
135
+ for (let t = first; t <= view.end + 1e-9 && out.length < 500; t += step) out.push(round(t))
136
+ return out
137
+ }
138
+
139
+ /**
140
+ * Where the picture sits in a box that shows it whole (object-fit: contain):
141
+ * the region is drawn on the picture, not on the black bars around it.
142
+ */
143
+ export function containRect(boxWidth: number, boxHeight: number, videoWidth: number, videoHeight: number) {
144
+ if (!videoWidth || !videoHeight || !boxWidth || !boxHeight) return { x: 0, y: 0, width: boxWidth, height: boxHeight }
145
+ const scale = Math.min(boxWidth / videoWidth, boxHeight / videoHeight)
146
+ const width = videoWidth * scale
147
+ const height = videoHeight * scale
148
+ return { x: (boxWidth - width) / 2, y: (boxHeight - height) / 2, width, height }
149
+ }
150
+
151
+ const fix = (n: number) => Math.round(n * 10000) / 10000
152
+
153
+ /** A region held inside the frame and no smaller than `minSize` a side. */
154
+ export function clampRegion(r: FrameRegion, minSize = 0.02): FrameRegion {
155
+ const w = clamp(r.w, minSize, 1)
156
+ const h = clamp(r.h, minSize, 1)
157
+ return { x: fix(clamp(r.x, 0, 1 - w)), y: fix(clamp(r.y, 0, 1 - h)), w: fix(w), h: fix(h) }
158
+ }
159
+
160
+ /** The region spanned by two corners, in either order, cut to the frame. */
161
+ export function regionBetween(ax: number, ay: number, bx: number, by: number): FrameRegion {
162
+ const x0 = clamp(Math.min(ax, bx), 0, 1)
163
+ const y0 = clamp(Math.min(ay, by), 0, 1)
164
+ const x1 = clamp(Math.max(ax, bx), 0, 1)
165
+ const y1 = clamp(Math.max(ay, by), 0, 1)
166
+ return { x: fix(x0), y: fix(y0), w: fix(x1 - x0), h: fix(y1 - y0) }
167
+ }
168
+
169
+ export type RegionHandle = "move" | "nw" | "ne" | "sw" | "se"
170
+
171
+ /** The region after one of its handles moved by (dx, dy), fractions of the frame. */
172
+ export function adjustRegion(r: FrameRegion, handle: RegionHandle, dx: number, dy: number, minSize = 0.02): FrameRegion {
173
+ if (handle === "move") return clampRegion({ ...r, x: r.x + dx, y: r.y + dy }, minSize)
174
+ let x0 = r.x
175
+ let y0 = r.y
176
+ let x1 = r.x + r.w
177
+ let y1 = r.y + r.h
178
+ if (handle === "nw" || handle === "sw") x0 = clamp(x0 + dx, 0, x1 - minSize)
179
+ else x1 = clamp(x1 + dx, x0 + minSize, 1)
180
+ if (handle === "nw" || handle === "ne") y0 = clamp(y0 + dy, 0, y1 - minSize)
181
+ else y1 = clamp(y1 + dy, y0 + minSize, 1)
182
+ return { x: fix(x0), y: fix(y0), w: fix(x1 - x0), h: fix(y1 - y0) }
183
+ }
184
+
185
+ /**
186
+ * Why the editor has no video to show, as its `error` event reports it:
187
+ * - "load": the address did not answer (network, 404, an expired session);
188
+ * - "decode": the file arrived but is damaged;
189
+ * - "unsupported": a format or codec this browser cannot play (H.265 in
190
+ * Chromium without hardware support, ProRes anywhere).
191
+ */
192
+ export type VideoTrimErrorCause = "load" | "decode" | "unsupported"
193
+
194
+ export function mediaErrorCause(error: MediaError | null): VideoTrimErrorCause {
195
+ if (error?.code === 3) return "decode"
196
+ if (error?.code === 4) return "unsupported"
197
+ return "load"
198
+ }
@@ -31,6 +31,10 @@
31
31
  * entries keep working once Back returns past it.
32
32
  * - Someone else's `replaceState` on a kit entry keeps the kit's key, so a
33
33
  * router saving its scroll position does not orphan the entry.
34
+ * - Someone else's `pushState` or `replaceState` while a step the kit started
35
+ * is on its way (closing an overlay and following a link in it in one tick)
36
+ * is held until the step lands, then applied there: the address changes a
37
+ * traversal later than the router's own route does.
34
38
  * - An app that keeps an overlay in its query through `replace` rewrites the
35
39
  * address of the kit's entry when the overlay closes, and the entry under it
36
40
  * still holds the old one. Taking the entry out lands there, so the address
@@ -362,7 +366,7 @@ function install() {
362
366
 
363
367
  const push = history.pushState.bind(history)
364
368
  const replace = history.replaceState.bind(history)
365
- history.pushState = function (data: unknown, unused: string, url?: string | URL | null) {
369
+ const pushed = (data: unknown, unused: string, url?: string | URL | null) => {
366
370
  const before = location.href
367
371
  push(data, unused, url)
368
372
  s.href = location.href
@@ -371,12 +375,35 @@ function install() {
371
375
  if (pathOf(before) !== pathOf(location.href)) leavePage()
372
376
  mute()
373
377
  }
374
- history.replaceState = function (data: unknown, unused: string, url?: string | URL | null) {
378
+ const replaced = (data: unknown, unused: string, url?: string | URL | null) => {
375
379
  const mark = s.internal ? null : current()
376
380
  const keep = mark && data && typeof data === "object" && !markOf(data) ? { ...(data as object), [KEY]: mark } : data
377
381
  replace(keep, unused, url)
378
382
  s.href = location.href
379
383
  }
384
+ history.pushState = function (data: unknown, unused: string, url?: string | URL | null) {
385
+ if (!s.internal && s.silent > 0) s.waiting.push(() => pushed(unmarked(data), unused, url))
386
+ else pushed(data, unused, url)
387
+ }
388
+ history.replaceState = function (data: unknown, unused: string, url?: string | URL | null) {
389
+ if (!s.internal && s.silent > 0) s.waiting.push(() => replaced(unmarked(data), unused, url))
390
+ else replaced(data, unused, url)
391
+ }
392
+ }
393
+
394
+ /*
395
+ * A change written while a step the kit started is on its way (an overlay
396
+ * closed and a link inside it navigated in the same tick) waits for the step to
397
+ * land. Applied at once, the step would then go back from the new entry, and
398
+ * the page would end on the old path. A router builds its state from the entry
399
+ * it sat on, the kit's, so the kit's key is taken off: the entry it lands on is
400
+ * not the kit's any more.
401
+ */
402
+ function unmarked(data: unknown): unknown {
403
+ if (!markOf(data)) return data
404
+ const copy = { ...(data as Record<string, unknown>) }
405
+ delete copy[KEY]
406
+ return copy
380
407
  }
381
408
 
382
409
  /** Record an entry on top of the history, under the current address. */
@@ -0,0 +1,21 @@
1
+ /*
2
+ * A window onto a line (a chart's points, a video's seconds): zoomed around a
3
+ * point that stays put, or slid along without changing its width. Shared by
4
+ * LpChart's window and LpVideoTrim's timeline, which differ only in what they
5
+ * round to and how they hold the result inside their bounds.
6
+ */
7
+
8
+ /** The window zoomed by `factor` (below 1 is in) around `anchor`, a position on the line; no wider than it was asked, no narrower than `minSpan`. */
9
+ export function zoomSpan(start: number, end: number, anchor: number, factor: number, minSpan: number): { start: number; end: number } {
10
+ const span = end - start
11
+ const next = Math.max(minSpan, span * factor)
12
+ const from = anchor - (anchor - start) * (next / (span || 1))
13
+ return { start: from, end: from + next }
14
+ }
15
+
16
+ /** The window moved by `delta`, its width kept, stopped at `lo` and `hi`. */
17
+ export function shiftSpan(start: number, end: number, delta: number, lo: number, hi: number): { start: number; end: number } {
18
+ const span = end - start
19
+ const from = Math.max(lo, Math.min(hi - span, start + delta))
20
+ return { start: from, end: from + span }
21
+ }
package/src/index.ts CHANGED
@@ -226,6 +226,9 @@ export { default as LpRangeSlider } from "./components/LpRangeSlider.vue"
226
226
  export { default as LpVideoBackdrop } from "./components/LpVideoBackdrop.vue"
227
227
  export { formatClipTime, isNativeLoop, planLoop, seamProgress, seamReached } from "./components/videoBackdrop"
228
228
  export type { LoopPlan } from "./components/videoBackdrop"
229
+ export { default as LpVideoTrim } from "./components/LpVideoTrim.vue"
230
+ export { dragEnd, normalizeRange as normalizeTrimRange, parseClipTime, setEnd } from "./components/videoTrim"
231
+ export type { FrameRegion, TimeRange, VideoTrimErrorCause } from "./components/videoTrim"
229
232
  export { default as LpSparkline } from "./components/LpSparkline.vue"
230
233
  export { default as LpStat } from "./components/LpStat.vue"
231
234
  export { default as LpStepper } from "./components/LpStepper.vue"