@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
@@ -0,0 +1,924 @@
1
+ <script lang="ts">
2
+ export default { inheritAttrs: false }
3
+ </script>
4
+
5
+ <script setup lang="ts">
6
+ /*
7
+ * Pick a stretch of a video, and optionally a rectangle on its picture, for a
8
+ * server to cut (ffmpeg) or blur. Nothing is cut here: the component reports
9
+ * `[start, end]` in seconds and a region in fractions of the frame.
10
+ *
11
+ * <LpVideoTrim v-model:range="range" :src="url" :max-clip="60" @change="save" />
12
+ *
13
+ * - The video plays from its address the way a <video> does: by range
14
+ * requests, never downloaded whole, so an hour or a gigabyte costs what is
15
+ * watched. A same-origin address keeps the session cookie (no `crossorigin`
16
+ * is set: it would drop it), and the timeline's stills need no CORS either.
17
+ * - The timeline zooms (wheel, pinch, the buttons) down to a few seconds
18
+ * across, so an hour-long recording still trims to the frame; when zoomed, a
19
+ * thin overview under it shows and moves the visible part.
20
+ * - Keys, wherever focus is inside: Space plays, ←/→ step a frame (Shift: a
21
+ * second), I and O set the start and end at the playhead. On a focused
22
+ * handle the arrows move that handle.
23
+ * - `update:range` follows every move; `change` fires once a move is done (a
24
+ * handle let go, a key, a typed time), for the save.
25
+ * - `maxClip` and `minClip` bound the length: a dragged handle stops at the
26
+ * limit, while I, O and a typed time move the other end along.
27
+ */
28
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
29
+ import { startPointerDrag } from "../composables/pointerDrag"
30
+ import { isHTMLElement } from "../composables/realm"
31
+ import { useLabels } from "../composables/useLpConfig"
32
+ import { useMergedAttrs } from "../composables/useMergedClass"
33
+ import { useResizeObserver } from "../composables/useResizeObserver"
34
+ import { usePrefersReducedMotion } from "../composables/useReducedMotion"
35
+ import LpButton from "./LpButton.vue"
36
+ import LpIcon from "./LpIcon.vue"
37
+ import LpInput from "./LpInput.vue"
38
+ import { formatClipTime } from "./videoBackdrop"
39
+ import { FrameSampler, stillSpan } from "./videoFrames"
40
+ import {
41
+ adjustRegion,
42
+ containRect,
43
+ dragEnd,
44
+ mediaErrorCause,
45
+ normalizeRange,
46
+ parseClipTime,
47
+ regionBetween,
48
+ revealTime,
49
+ setEnd,
50
+ shiftView,
51
+ tickStep,
52
+ ticks,
53
+ zoomView,
54
+ type FrameRegion,
55
+ type RegionHandle,
56
+ type TimeRange,
57
+ type TimeView,
58
+ type VideoTrimErrorCause,
59
+ } from "./videoTrim"
60
+
61
+ const props = withDefaults(
62
+ defineProps<{
63
+ src: string
64
+ poster?: string
65
+ /** `[start, end]` in seconds; the whole video (or the first `maxClip`) when unset. */
66
+ range?: TimeRange | null
67
+ /** The longest clip allowed, in seconds (a chat's upload limit). */
68
+ maxClip?: number
69
+ /** The shortest clip allowed, in seconds. */
70
+ minClip?: number
71
+ /** Lets a rectangle be drawn on the picture (`v-model:region`), for a part to blur. */
72
+ regionSelect?: boolean
73
+ /** The marked rectangle, fractions of the frame (0..1); null when none. */
74
+ region?: FrameRegion | null
75
+ /** Frames per second, for the size of a frame step. */
76
+ frameRate?: number
77
+ disabled?: boolean
78
+ labels?: Partial<Record<
79
+ | "play" | "pause" | "stepBack" | "stepForward" | "mute" | "unmute" | "zoomIn" | "zoomOut" | "zoomFit"
80
+ | "start" | "end" | "position" | "length" | "left" | "overview" | "timeline"
81
+ | "region" | "regionHint" | "regionClear" | "failed" | "unsupported" | "invalidTime",
82
+ string
83
+ >>
84
+ }>(),
85
+ { poster: undefined, range: undefined, maxClip: undefined, minClip: 0.1, region: null, frameRate: 30, labels: () => ({}) },
86
+ )
87
+
88
+ const emit = defineEmits<{
89
+ (e: "update:range", value: TimeRange): void
90
+ /** The range after a finished move: a handle let go, a key, a typed time. */
91
+ (e: "change", value: TimeRange): void
92
+ (e: "update:region", value: FrameRegion | null): void
93
+ (e: "error", error: { cause: VideoTrimErrorCause; message: string }): void
94
+ }>()
95
+
96
+ const text = useLabels(
97
+ "videoTrim",
98
+ {
99
+ play: "Play",
100
+ pause: "Pause",
101
+ stepBack: "Previous frame",
102
+ stepForward: "Next frame",
103
+ mute: "Mute",
104
+ unmute: "Unmute",
105
+ zoomIn: "Zoom in",
106
+ zoomOut: "Zoom out",
107
+ zoomFit: "Show whole video",
108
+ start: "Start",
109
+ end: "End",
110
+ position: "Position",
111
+ length: "Length",
112
+ /** `{left}` and `{max}` are replaced by times. */
113
+ left: "{left} left of {max}",
114
+ overview: "Visible part of the timeline",
115
+ timeline: "Timeline — Space plays, arrows step, I and O set start and end",
116
+ region: "Marked area — arrows move it, Shift+arrows resize, Delete removes",
117
+ regionHint: "Drag on the picture to mark an area",
118
+ regionClear: "Remove area",
119
+ failed: "This video couldn't be loaded.",
120
+ unsupported: "This browser can't play this video's format.",
121
+ invalidTime: "Type a time like 1:05.5",
122
+ },
123
+ () => props.labels,
124
+ )
125
+
126
+ const { class: rootClass, attrs: rest } = useMergedAttrs("flex flex-col gap-3 select-none")
127
+ const reduced = usePrefersReducedMotion()
128
+
129
+ const videoEl = ref<HTMLVideoElement | null>(null)
130
+ const samplerEl = ref<HTMLVideoElement | null>(null)
131
+ const stageEl = ref<HTMLElement | null>(null)
132
+ const trackEl = ref<HTMLElement | null>(null)
133
+
134
+ const duration = ref(0)
135
+ const failure = ref<VideoTrimErrorCause | null>(null)
136
+ const time = ref(0)
137
+ const playing = ref(false)
138
+ const muted = ref(false)
139
+ const videoSize = ref({ width: 0, height: 0 })
140
+ const stageSize = ref({ width: 0, height: 0 })
141
+ const trackWidth = ref(0)
142
+ const ready = computed(() => duration.value > 0 && !failure.value)
143
+ const off = computed(() => props.disabled || !ready.value)
144
+
145
+ const limits = computed(() => ({
146
+ duration: duration.value,
147
+ minClip: Math.max(0, props.minClip ?? 0),
148
+ maxClip: props.maxClip && props.maxClip > 0 ? props.maxClip : Infinity,
149
+ }))
150
+
151
+ /* ---------- range ---------- */
152
+
153
+ const local = ref<TimeRange>([0, 0])
154
+ /** Which handle a pointer holds, so the prop echoing back mid-drag does not fight it. */
155
+ const dragging = ref<0 | 1 | null>(null)
156
+
157
+ const same = (a: TimeRange | null | undefined, b: TimeRange) => !!a && a[0] === b[0] && a[1] === b[1]
158
+
159
+ watch(
160
+ [() => props.range, limits],
161
+ () => {
162
+ if (dragging.value !== null || !duration.value) return
163
+ const next = normalizeRange(props.range, limits.value)
164
+ local.value = next
165
+ // An unset or impossible range is replaced by the one the editor shows, so the model never disagrees with it.
166
+ if (!same(props.range, next)) emit("update:range", next)
167
+ },
168
+ { deep: true },
169
+ )
170
+
171
+ function put(next: TimeRange, commit: boolean) {
172
+ const changed = !same(local.value, next)
173
+ local.value = next
174
+ if (changed) emit("update:range", next)
175
+ if (commit) emit("change", next)
176
+ }
177
+
178
+ const clipLength = computed(() => local.value[1] - local.value[0])
179
+
180
+ /* ---------- media ---------- */
181
+
182
+ let seekTarget: number | null = null
183
+ let seekBusy = false
184
+ /*
185
+ * One seek at a time, the latest wins: a drag asks for a position each pointer
186
+ * move, and WebKitGTK's pipeline stalls on a seek started inside another.
187
+ */
188
+ function seek(t: number) {
189
+ const video = videoEl.value
190
+ const target = Math.min(Math.max(0, t), duration.value || t)
191
+ time.value = target
192
+ if (!video) return
193
+ if (seekBusy) {
194
+ seekTarget = target
195
+ return
196
+ }
197
+ seekBusy = true
198
+ video.currentTime = target
199
+ }
200
+ function onSeeked() {
201
+ seekBusy = false
202
+ if (seekTarget !== null) {
203
+ const next = seekTarget
204
+ seekTarget = null
205
+ seek(next)
206
+ } else if (videoEl.value && !playing.value) time.value = videoEl.value.currentTime
207
+ }
208
+
209
+ let probingDuration = false
210
+ function onMetadata() {
211
+ const video = videoEl.value
212
+ if (!video) return
213
+ videoSize.value = { width: video.videoWidth, height: video.videoHeight }
214
+ if (Number.isFinite(video.duration)) {
215
+ duration.value = video.duration
216
+ return
217
+ }
218
+ // A MediaRecorder WebM carries no duration until its end has been seen: seek far past it once to learn it.
219
+ probingDuration = true
220
+ video.currentTime = 1e101
221
+ }
222
+ function onDurationChange() {
223
+ const video = videoEl.value
224
+ if (!video || !Number.isFinite(video.duration) || !video.duration) return
225
+ duration.value = video.duration
226
+ if (probingDuration) {
227
+ probingDuration = false
228
+ video.currentTime = 0
229
+ }
230
+ }
231
+ async function onError() {
232
+ const video = videoEl.value
233
+ if (!video?.error) return
234
+ const src = props.src
235
+ let cause = mediaErrorCause(video.error)
236
+ // Chromium answers a 404, or a login page served in place of an expired session's video, with "format not supported" too.
237
+ if (cause === "unsupported") cause = await probeSource(src)
238
+ if (src !== props.src) return
239
+ failure.value = cause
240
+ emit("error", { cause, message: cause === "unsupported" ? text.value.unsupported : text.value.failed })
241
+ }
242
+ /* One byte of the address: whether it answers with media at all. Unknown (another origin without CORS) stays "unsupported". */
243
+ async function probeSource(src: string): Promise<VideoTrimErrorCause> {
244
+ try {
245
+ const res = await fetch(src, { headers: { Range: "bytes=0-0" }, credentials: "same-origin" })
246
+ void res.body?.cancel()
247
+ const type = res.headers.get("content-type") ?? ""
248
+ return res.ok && !/^text\/|json|xml/.test(type) ? "unsupported" : "load"
249
+ } catch {
250
+ return "unsupported"
251
+ }
252
+ }
253
+
254
+ /** Playback that began inside the range ends at its end, so Play previews the clip. */
255
+ let stopAtEnd = false
256
+ let frame = 0
257
+ function tick() {
258
+ frame = 0
259
+ const video = videoEl.value
260
+ if (!video || !playing.value) return
261
+ time.value = video.currentTime
262
+ if (stopAtEnd && video.currentTime >= local.value[1]) {
263
+ video.pause()
264
+ seek(local.value[1])
265
+ return
266
+ }
267
+ if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value, 0.05)
268
+ frame = requestAnimationFrame(tick)
269
+ }
270
+ function onPlay() {
271
+ playing.value = true
272
+ if (!frame) frame = requestAnimationFrame(tick)
273
+ }
274
+ function onPause() {
275
+ playing.value = false
276
+ if (frame) cancelAnimationFrame(frame)
277
+ frame = 0
278
+ if (videoEl.value && !seekBusy) time.value = videoEl.value.currentTime
279
+ }
280
+
281
+ function togglePlay() {
282
+ const video = videoEl.value
283
+ if (!video || off.value) return
284
+ if (!video.paused) {
285
+ video.pause()
286
+ return
287
+ }
288
+ const [a, b] = local.value
289
+ let from = time.value
290
+ if (from >= b - 1e-3 && from <= b + 1e-3) from = a
291
+ stopAtEnd = from >= a - 1e-3 && from < b
292
+ if (from !== time.value) seek(from)
293
+ void video.play().catch(() => {})
294
+ }
295
+ function pause() {
296
+ videoEl.value?.pause()
297
+ }
298
+ function step(seconds: number) {
299
+ if (off.value) return
300
+ pause()
301
+ seek(time.value + seconds)
302
+ if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value)
303
+ }
304
+ function toggleMute() {
305
+ muted.value = !muted.value
306
+ if (videoEl.value) videoEl.value.muted = muted.value
307
+ }
308
+
309
+ const frameStep = computed(() => 1 / Math.max(1, props.frameRate))
310
+
311
+ /* ---------- timeline view ---------- */
312
+
313
+ /* The narrowest view: a couple of seconds across still leaves frames a few pixels apart. */
314
+ const MIN_SPAN = 2
315
+ const view = ref<TimeView>({ start: 0, end: 0 })
316
+ const span = computed(() => Math.max(1e-6, view.value.end - view.value.start))
317
+ const zoomed = computed(() => duration.value > 0 && span.value < duration.value - 1e-6)
318
+ const canZoomIn = computed(() => span.value > Math.min(MIN_SPAN, duration.value) + 1e-6)
319
+
320
+ watch(duration, (d) => {
321
+ view.value = { start: 0, end: d }
322
+ })
323
+
324
+ const xOf = (t: number) => ((t - view.value.start) / span.value) * trackWidth.value
325
+ const pct = (t: number) => `${((t - view.value.start) / span.value) * 100}%`
326
+ function timeAt(clientX: number): number {
327
+ const box = trackEl.value?.getBoundingClientRect()
328
+ if (!box || !box.width) return 0
329
+ return view.value.start + ((clientX - box.left) / box.width) * span.value
330
+ }
331
+
332
+ function zoomBy(factor: number, anchor = time.value) {
333
+ if (!duration.value) return
334
+ view.value = zoomView(view.value, anchor, factor, duration.value, MIN_SPAN)
335
+ }
336
+ function fit() {
337
+ view.value = { start: 0, end: duration.value }
338
+ }
339
+
340
+ function onWheel(event: WheelEvent) {
341
+ if (!ready.value) return
342
+ const dx = event.shiftKey ? event.deltaY : event.deltaX
343
+ // A sideways scroll (or Shift+wheel) pans; an upright one, and a pinch on a touchpad, zooms.
344
+ if (Math.abs(dx) > Math.abs(event.deltaY) || event.shiftKey) {
345
+ if (!zoomed.value) return
346
+ event.preventDefault()
347
+ view.value = shiftView(view.value, (dx / Math.max(1, trackWidth.value)) * span.value, duration.value)
348
+ return
349
+ }
350
+ event.preventDefault()
351
+ const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1
352
+ zoomBy(Math.exp((-event.deltaY * unit) / (event.ctrlKey ? 100 : 300)), timeAt(event.clientX))
353
+ }
354
+
355
+ const marks = computed(() => (ready.value ? ticks(view.value, tickStep(span.value, trackWidth.value)) : []))
356
+ const markDigits = computed(() => tickStep(span.value, trackWidth.value) < 1)
357
+
358
+ /* ---------- timeline pointers ---------- */
359
+
360
+ const pointers = new Map<number, number>()
361
+ let pinch: { distance: number; anchor: number } | null = null
362
+ let edgeFrame = 0
363
+ let lastX = 0
364
+ let scrubbing = false
365
+
366
+ function onHandleDown(end: 0 | 1, event: PointerEvent) {
367
+ if (off.value || event.button !== 0) return
368
+ event.stopPropagation()
369
+ ;(event.currentTarget as HTMLElement).focus({ preventScroll: true })
370
+ pause()
371
+ dragging.value = end
372
+ lastX = event.clientX
373
+ startPointerDrag(event, {
374
+ cursor: "ew-resize",
375
+ move: (e) => {
376
+ lastX = e.clientX
377
+ moveHandle()
378
+ edgeScroll()
379
+ },
380
+ end: () => {
381
+ dragging.value = null
382
+ if (edgeFrame) cancelAnimationFrame(edgeFrame)
383
+ edgeFrame = 0
384
+ emit("change", local.value)
385
+ },
386
+ })
387
+ }
388
+ function moveHandle() {
389
+ const end = dragging.value
390
+ if (end === null) return
391
+ const next = dragEnd(local.value, end, timeAt(lastX), limits.value)
392
+ put(next, false)
393
+ // The picture shows the frame being cut at.
394
+ seek(next[end])
395
+ }
396
+ /* Held past either edge of a zoomed timeline, the view travels, faster the further out. */
397
+ function edgeScroll() {
398
+ if (edgeFrame || !zoomed.value) return
399
+ const box = trackEl.value?.getBoundingClientRect()
400
+ if (!box) return
401
+ const over = lastX < box.left ? lastX - box.left : lastX > box.right ? lastX - box.right : 0
402
+ if (!over || (dragging.value === null && !scrubbing)) return
403
+ edgeFrame = requestAnimationFrame(() => {
404
+ edgeFrame = 0
405
+ view.value = shiftView(view.value, (Math.max(-60, Math.min(60, over)) / 6 / Math.max(1, box.width)) * span.value, duration.value)
406
+ if (dragging.value !== null) moveHandle()
407
+ else seek(timeAt(lastX))
408
+ edgeScroll()
409
+ })
410
+ }
411
+
412
+ function onTrackDown(event: PointerEvent) {
413
+ if (off.value || (event.pointerType === "mouse" && event.button !== 0)) return
414
+ ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
415
+ pointers.set(event.pointerId, event.clientX)
416
+ if (pointers.size === 2) {
417
+ scrubbing = false
418
+ const [a, b] = [...pointers.values()] as [number, number]
419
+ pinch = { distance: Math.max(8, Math.abs(a - b)), anchor: timeAt((a + b) / 2) }
420
+ return
421
+ }
422
+ if (pointers.size > 2) return
423
+ event.preventDefault()
424
+ trackEl.value?.querySelector<HTMLElement>("[data-lp-playhead]")?.focus({ preventScroll: true })
425
+ pause()
426
+ scrubbing = true
427
+ lastX = event.clientX
428
+ seek(timeAt(event.clientX))
429
+ }
430
+ function onTrackMove(event: PointerEvent) {
431
+ if (!pointers.has(event.pointerId)) return
432
+ pointers.set(event.pointerId, event.clientX)
433
+ if (pinch && pointers.size === 2) {
434
+ const [a, b] = [...pointers.values()] as [number, number]
435
+ const distance = Math.max(8, Math.abs(a - b))
436
+ zoomBy(distance / pinch.distance, pinch.anchor)
437
+ pinch.distance = distance
438
+ return
439
+ }
440
+ if (!scrubbing) return
441
+ lastX = event.clientX
442
+ seek(timeAt(event.clientX))
443
+ edgeScroll()
444
+ }
445
+ function onTrackUp(event: PointerEvent) {
446
+ pointers.delete(event.pointerId)
447
+ if (pointers.size < 2) pinch = null
448
+ if (!pointers.size) scrubbing = false
449
+ }
450
+
451
+ /* The overview: press or drag to centre the view there. */
452
+ function onOverviewDown(event: PointerEvent) {
453
+ if (off.value || event.button !== 0) return
454
+ const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
455
+ const centreAt = (clientX: number) => {
456
+ const centre = ((clientX - box.left) / Math.max(1, box.width)) * duration.value
457
+ view.value = shiftView(view.value, centre - (view.value.start + view.value.end) / 2, duration.value)
458
+ }
459
+ centreAt(event.clientX)
460
+ startPointerDrag(event, { move: (e) => centreAt(e.clientX), end: () => {} })
461
+ }
462
+
463
+ /* ---------- stills ---------- */
464
+
465
+ const STILL_HEIGHT = 56
466
+ const stillWidth = computed(() => {
467
+ const { width, height } = videoSize.value
468
+ return Math.round(Math.min(160, Math.max(40, width && height ? (STILL_HEIGHT * width) / height : STILL_HEIGHT * (16 / 9))))
469
+ })
470
+ const stillSeconds = computed(() => stillSpan(span.value / Math.max(1, trackWidth.value), stillWidth.value))
471
+ const stills = computed(() => {
472
+ if (!ready.value || !trackWidth.value) return []
473
+ const step = stillSeconds.value
474
+ const first = Math.floor(view.value.start / step)
475
+ const last = Math.min(Math.ceil(view.value.end / step), Math.ceil(duration.value / step))
476
+ const out: { key: string; time: number; left: string; width: string }[] = []
477
+ for (let k = first; k < last && out.length < 200; k++) {
478
+ out.push({ key: `${step}:${k}`, time: Math.min(duration.value, k * step + step / 2), left: pct(k * step), width: `${(step / span.value) * 100}%` })
479
+ }
480
+ return out
481
+ })
482
+ const frames = shallowRef(new Map<string, HTMLCanvasElement>())
483
+ let sampler: FrameSampler | null = null
484
+ let stillTimer: ReturnType<typeof setTimeout> | undefined
485
+
486
+ watch(stills, (list) => {
487
+ clearTimeout(stillTimer)
488
+ // Wait for a zoom or a pan to come to rest before asking for stills of a view that may be gone in a moment.
489
+ stillTimer = setTimeout(() => {
490
+ if (!sampler) return
491
+ const centre = (view.value.start + view.value.end) / 2
492
+ sampler.want([...list].sort((a, b) => Math.abs(a.time - centre) - Math.abs(b.time - centre)))
493
+ }, 120)
494
+ })
495
+
496
+ function mountFrame(key: string, el: Element | null) {
497
+ if (!isHTMLElement(el)) return
498
+ const canvas = frames.value.get(key) ?? sampler?.cached(key)
499
+ if (canvas && canvas.parentElement !== el) el.replaceChildren(canvas)
500
+ }
501
+
502
+ /* ---------- region ---------- */
503
+
504
+ const picture = computed(() => containRect(stageSize.value.width, stageSize.value.height, videoSize.value.width, videoSize.value.height))
505
+ const draft = ref<FrameRegion | null>(null)
506
+ const shownRegion = computed(() => draft.value ?? props.region ?? null)
507
+
508
+ function fractionAt(event: PointerEvent) {
509
+ const box = stageEl.value?.getBoundingClientRect()
510
+ const p = picture.value
511
+ if (!box || !p.width || !p.height) return { x: 0, y: 0 }
512
+ return { x: (event.clientX - box.left - p.x) / p.width, y: (event.clientY - box.top - p.y) / p.height }
513
+ }
514
+ function onRegionDown(handle: RegionHandle | "draw", event: PointerEvent) {
515
+ if (props.disabled || !props.regionSelect || (event.pointerType === "mouse" && event.button !== 0)) return
516
+ event.stopPropagation()
517
+ pause()
518
+ const start = fractionAt(event)
519
+ const from = props.region ?? null
520
+ startPointerDrag(event, {
521
+ move: (e) => {
522
+ const at = fractionAt(e)
523
+ draft.value = handle === "draw" || !from ? regionBetween(start.x, start.y, at.x, at.y) : adjustRegion(from, handle, at.x - start.x, at.y - start.y)
524
+ },
525
+ end: (completed) => {
526
+ const next = draft.value
527
+ draft.value = null
528
+ // A press without a drag is not a region: a stray click must not leave a sliver to blur.
529
+ if (!completed || !next || (handle === "draw" && (next.w < 0.01 || next.h < 0.01))) return
530
+ emit("update:region", next)
531
+ },
532
+ })
533
+ }
534
+ function onRegionKey(event: KeyboardEvent) {
535
+ const r = props.region
536
+ if (!r || props.disabled) return
537
+ if (event.key === "Delete" || event.key === "Backspace") {
538
+ event.preventDefault()
539
+ emit("update:region", null)
540
+ return
541
+ }
542
+ const d = event.altKey ? 0.002 : 0.01
543
+ const moves: Record<string, [number, number]> = { ArrowLeft: [-d, 0], ArrowRight: [d, 0], ArrowUp: [0, -d], ArrowDown: [0, d] }
544
+ const move = moves[event.key]
545
+ if (!move) return
546
+ event.preventDefault()
547
+ event.stopPropagation()
548
+ emit("update:region", adjustRegion(r, event.shiftKey ? "se" : "move", move[0], move[1]))
549
+ }
550
+
551
+ /* ---------- keys ---------- */
552
+
553
+ function onKeydown(event: KeyboardEvent) {
554
+ const target = event.target as HTMLElement
555
+ if (event.defaultPrevented || event.ctrlKey || event.metaKey || target.closest("input, textarea, select, [contenteditable='true']")) return
556
+ const handle = target.dataset.lpHandle
557
+ const end = handle === "0" ? 0 : handle === "1" ? 1 : null
558
+ const big = event.shiftKey ? 1 : frameStep.value
559
+ const delta: Record<string, number> = { ArrowLeft: -big, ArrowRight: big, ArrowDown: -big, ArrowUp: big, PageDown: -10, PageUp: 10 }
560
+ if (event.key === " " && target.tagName !== "BUTTON") {
561
+ event.preventDefault()
562
+ togglePlay()
563
+ } else if (event.key in delta || event.key === "Home" || event.key === "End") {
564
+ if (off.value) return
565
+ event.preventDefault()
566
+ if (end !== null) {
567
+ const from = local.value[end]
568
+ const to = event.key === "Home" ? 0 : event.key === "End" ? duration.value : from + delta[event.key]!
569
+ const next = dragEnd(local.value, end, to, limits.value)
570
+ put(next, true)
571
+ pause()
572
+ seek(next[end])
573
+ } else if (event.key === "Home" || event.key === "End") {
574
+ pause()
575
+ seek(event.key === "Home" ? 0 : duration.value)
576
+ } else step(delta[event.key]!)
577
+ if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value)
578
+ } else if ((event.key === "i" || event.key === "I" || event.key === "o" || event.key === "O") && !event.altKey) {
579
+ if (off.value) return
580
+ event.preventDefault()
581
+ put(setEnd(local.value, event.key.toLowerCase() === "i" ? 0 : 1, time.value, limits.value), true)
582
+ }
583
+ }
584
+
585
+ /* ---------- typed times ---------- */
586
+
587
+ const drafts = ref<[string | null, string | null]>([null, null])
588
+ const invalid = ref<[boolean, boolean]>([false, false])
589
+ const fieldText = (end: 0 | 1) => drafts.value[end] ?? formatClipTime(local.value[end], true)
590
+ function onFieldInput(end: 0 | 1, value: string) {
591
+ drafts.value[end] = value
592
+ invalid.value[end] = false
593
+ }
594
+ function commitField(end: 0 | 1) {
595
+ const typed = drafts.value[end]
596
+ if (typed === null) return
597
+ const t = parseClipTime(typed)
598
+ if (t === null) {
599
+ invalid.value[end] = true
600
+ return
601
+ }
602
+ drafts.value[end] = null
603
+ put(setEnd(local.value, end, t, limits.value), true)
604
+ seek(local.value[end])
605
+ if (zoomed.value) view.value = revealTime(view.value, local.value[end], duration.value)
606
+ }
607
+ function revertField(end: 0 | 1) {
608
+ drafts.value[end] = null
609
+ invalid.value[end] = false
610
+ }
611
+
612
+ /* ---------- mount ---------- */
613
+
614
+ useResizeObserver(trackEl, (_, el) => (trackWidth.value = (el as HTMLElement).clientWidth), { immediate: true })
615
+ useResizeObserver(stageEl, (_, el) => (stageSize.value = { width: (el as HTMLElement).clientWidth, height: (el as HTMLElement).clientHeight }), { immediate: true })
616
+
617
+ function startSampler() {
618
+ sampler?.dispose()
619
+ sampler = samplerEl.value
620
+ ? new FrameSampler(samplerEl.value, () => ({ width: stillWidth.value, height: STILL_HEIGHT }), (key, canvas) => {
621
+ const next = new Map(frames.value)
622
+ next.set(key, canvas)
623
+ if (next.size > 240) next.delete(next.keys().next().value as string)
624
+ frames.value = next
625
+ })
626
+ : null
627
+ }
628
+
629
+ watch(
630
+ () => props.src,
631
+ () => {
632
+ duration.value = 0
633
+ failure.value = null
634
+ time.value = 0
635
+ frames.value = new Map()
636
+ seekBusy = false
637
+ seekTarget = null
638
+ void nextTick(startSampler)
639
+ },
640
+ )
641
+
642
+ onMounted(startSampler)
643
+ onBeforeUnmount(() => {
644
+ sampler?.dispose()
645
+ clearTimeout(stillTimer)
646
+ if (frame) cancelAnimationFrame(frame)
647
+ if (edgeFrame) cancelAnimationFrame(edgeFrame)
648
+ // Stop both streams from fetching on: an element that is merely detached keeps its connection.
649
+ for (const el of [videoEl.value, samplerEl.value]) {
650
+ if (!el) continue
651
+ el.pause()
652
+ el.removeAttribute("src")
653
+ el.load()
654
+ }
655
+ })
656
+
657
+ defineExpose({
658
+ play: () => (videoEl.value?.paused ? togglePlay() : undefined),
659
+ pause,
660
+ seek,
661
+ /** The playhead, in seconds. */
662
+ currentTime: () => time.value,
663
+ })
664
+
665
+ const handleText = (t: number) => formatClipTime(t, true)
666
+ const leftText = computed(() =>
667
+ Number.isFinite(limits.value.maxClip)
668
+ ? text.value.left
669
+ .replace("{left}", formatClipTime(Math.max(0, limits.value.maxClip - clipLength.value), true))
670
+ .replace("{max}", formatClipTime(limits.value.maxClip, true))
671
+ : "",
672
+ )
673
+ </script>
674
+
675
+ <template>
676
+ <div :class="rootClass" v-bind="rest" :data-disabled="disabled || undefined" @keydown="onKeydown">
677
+ <!-- The picture. Its own controls are off: the region is drawn on it, and the keys are the editor's. -->
678
+ <div
679
+ ref="stageEl"
680
+ class="relative aspect-video w-full overflow-hidden rounded-card bg-black"
681
+ >
682
+ <video
683
+ ref="videoEl"
684
+ class="absolute inset-0 size-full object-contain"
685
+ :src="src"
686
+ :poster="poster"
687
+ preload="metadata"
688
+ playsinline
689
+ :muted="muted"
690
+ @loadedmetadata="onMetadata"
691
+ @durationchange="onDurationChange"
692
+ @seeked="onSeeked"
693
+ @play="onPlay"
694
+ @pause="onPause"
695
+ @error="onError"
696
+ @click="!regionSelect && togglePlay()"
697
+ />
698
+ <!-- Stills come from a second element: seeking this one would move the picture under the person. -->
699
+ <video
700
+ ref="samplerEl"
701
+ class="pointer-events-none absolute size-px opacity-0"
702
+ :src="src"
703
+ preload="metadata"
704
+ muted
705
+ playsinline
706
+ aria-hidden="true"
707
+ tabindex="-1"
708
+ />
709
+ <div
710
+ v-if="regionSelect && ready"
711
+ class="absolute cursor-crosshair touch-none"
712
+ :style="{ left: `${picture.x}px`, top: `${picture.y}px`, width: `${picture.width}px`, height: `${picture.height}px` }"
713
+ data-lp-region-layer
714
+ @pointerdown="onRegionDown('draw', $event)"
715
+ >
716
+ <div
717
+ v-if="shownRegion"
718
+ class="absolute cursor-move rounded-inner border-2 border-dashed border-white bg-brand/25 shadow-[0_0_0_9999px_rgb(0_0_0/0.35)] outline-none focus-visible:ring-2 focus-visible:ring-ring"
719
+ :style="{ left: `${shownRegion.x * 100}%`, top: `${shownRegion.y * 100}%`, width: `${shownRegion.w * 100}%`, height: `${shownRegion.h * 100}%` }"
720
+ tabindex="0"
721
+ role="group"
722
+ :aria-label="text.region"
723
+ data-lp-region
724
+ @pointerdown="onRegionDown('move', $event)"
725
+ @keydown="onRegionKey"
726
+ >
727
+ <span
728
+ v-for="corner in (['nw', 'ne', 'sw', 'se'] as const)"
729
+ :key="corner"
730
+ class="absolute grid size-6 place-items-center pointer-coarse:size-11"
731
+ :class="[
732
+ corner[0] === 'n' ? 'top-0 -translate-y-1/2' : 'bottom-0 translate-y-1/2',
733
+ corner[1] === 'w' ? 'left-0 -translate-x-1/2' : 'right-0 translate-x-1/2',
734
+ corner === 'nw' || corner === 'se' ? 'cursor-nwse-resize' : 'cursor-nesw-resize',
735
+ ]"
736
+ :data-lp-corner="corner"
737
+ @pointerdown="onRegionDown(corner, $event)"
738
+ >
739
+ <span class="size-3 rounded-pill border-2 border-white bg-brand shadow" />
740
+ </span>
741
+ </div>
742
+ </div>
743
+ <div
744
+ v-if="failure"
745
+ role="alert"
746
+ class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-white/80"
747
+ >
748
+ <LpIcon name="lucide:video-off" :size="32" />
749
+ <p>{{ failure === "unsupported" ? text.unsupported : text.failed }}</p>
750
+ </div>
751
+ </div>
752
+
753
+ <!-- Transport and zoom. -->
754
+ <div class="flex flex-wrap items-center gap-1">
755
+ <LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="playing ? text.pause : text.play" :title="playing ? text.pause : text.play" @click="togglePlay">
756
+ <LpIcon :name="playing ? 'lucide:pause' : 'lucide:play'" :size="16" />
757
+ </LpButton>
758
+ <LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="text.stepBack" :title="text.stepBack" @click="step(-frameStep)">
759
+ <LpIcon name="lucide:step-back" :size="16" />
760
+ </LpButton>
761
+ <LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="text.stepForward" :title="text.stepForward" @click="step(frameStep)">
762
+ <LpIcon name="lucide:step-forward" :size="16" />
763
+ </LpButton>
764
+ <span class="px-1.5 font-mono text-xs text-muted tabular-nums" data-lp-clock>
765
+ <span class="text-ink">{{ formatClipTime(time, true) }}</span> / {{ formatClipTime(duration, true) }}
766
+ </span>
767
+ <LpButton size="sm" variant="ghost" square :disabled="!ready" :aria-label="muted ? text.unmute : text.mute" :title="muted ? text.unmute : text.mute" :aria-pressed="muted" @click="toggleMute">
768
+ <LpIcon :name="muted ? 'lucide:volume-x' : 'lucide:volume-2'" :size="16" />
769
+ </LpButton>
770
+ <span class="flex-1" />
771
+ <LpButton size="sm" variant="ghost" square :disabled="off || !zoomed" :aria-label="text.zoomOut" :title="text.zoomOut" @click="zoomBy(0.5)">
772
+ <LpIcon name="lucide:zoom-out" :size="16" />
773
+ </LpButton>
774
+ <LpButton size="sm" variant="ghost" square :disabled="off || !canZoomIn" :aria-label="text.zoomIn" :title="text.zoomIn" @click="zoomBy(2)">
775
+ <LpIcon name="lucide:zoom-in" :size="16" />
776
+ </LpButton>
777
+ <LpButton size="sm" variant="ghost" square :disabled="off || !zoomed" :aria-label="text.zoomFit" :title="text.zoomFit" @click="fit">
778
+ <LpIcon name="lucide:maximize-2" :size="16" />
779
+ </LpButton>
780
+ </div>
781
+
782
+ <!-- Ruler, stills, range and playhead. -->
783
+ <div class="relative" @wheel="onWheel">
784
+ <div class="relative h-4 overflow-hidden text-[10px] text-muted tabular-nums" aria-hidden="true">
785
+ <span
786
+ v-for="m in marks"
787
+ :key="m"
788
+ class="absolute top-0 whitespace-nowrap"
789
+ :class="xOf(m) < 16 ? '' : xOf(m) > trackWidth - 16 ? '-translate-x-full' : '-translate-x-1/2'"
790
+ :style="{ left: pct(m) }"
791
+ >{{ formatClipTime(m, markDigits) }}</span>
792
+ </div>
793
+ <div
794
+ ref="trackEl"
795
+ class="relative h-14 touch-none rounded-control bg-surface-soft"
796
+ :class="off ? 'opacity-60' : 'cursor-text'"
797
+ :aria-label="text.timeline"
798
+ role="group"
799
+ @pointerdown="onTrackDown"
800
+ @pointermove="onTrackMove"
801
+ @pointerup="onTrackUp"
802
+ @pointercancel="onTrackUp"
803
+ >
804
+ <div class="absolute inset-0 overflow-hidden rounded-control">
805
+ <div
806
+ v-for="s in stills"
807
+ :key="s.key"
808
+ :ref="(el) => mountFrame(s.key, el as Element | null)"
809
+ class="lp-trim-still absolute inset-y-0 overflow-hidden border-r border-black/20 [&>canvas]:size-full [&>canvas]:object-cover"
810
+ :class="{ 'lp-trim-still-in': !reduced }"
811
+ :style="{ left: s.left, width: s.width }"
812
+ :data-lp-still="frames.has(s.key) ? 'ready' : 'pending'"
813
+ />
814
+ <!-- What falls outside the clip is dimmed, not hidden: it is still where the clip could move. -->
815
+ <div class="absolute inset-y-0 left-0 bg-black/55" :style="{ width: `${Math.max(0, xOf(local[0]))}px` }" />
816
+ <div class="absolute inset-y-0 right-0 bg-black/55" :style="{ left: `${Math.min(trackWidth, Math.max(0, xOf(local[1])))}px` }" />
817
+ </div>
818
+ <div
819
+ v-if="ready"
820
+ class="pointer-events-none absolute inset-y-0 border-y-2 border-brand"
821
+ :style="{ left: pct(local[0]), width: `${(clipLength / span) * 100}%` }"
822
+ data-lp-range
823
+ />
824
+ <div
825
+ v-for="end in ([0, 1] as const)"
826
+ v-show="ready"
827
+ :key="end"
828
+ class="group/handle absolute inset-y-0 z-10 flex w-6 touch-none items-stretch outline-none pointer-coarse:w-11"
829
+ :class="[end === 0 ? 'justify-start' : '-translate-x-full justify-end', off ? '' : 'cursor-ew-resize']"
830
+ :style="{ left: pct(local[end]) }"
831
+ role="slider"
832
+ :tabindex="off ? -1 : 0"
833
+ :aria-label="end === 0 ? text.start : text.end"
834
+ :aria-valuemin="0"
835
+ :aria-valuemax="Math.round(duration * 1000) / 1000"
836
+ :aria-valuenow="local[end]"
837
+ :aria-valuetext="handleText(local[end])"
838
+ :aria-disabled="off || undefined"
839
+ :data-lp-handle="end"
840
+ @pointerdown="onHandleDown(end, $event)"
841
+ >
842
+ <span
843
+ class="flex w-3 items-center justify-center bg-brand transition-[width] duration-(--duration-fast) group-focus-visible/handle:ring-2 group-focus-visible/handle:ring-ring motion-reduce:transition-none"
844
+ :class="end === 0 ? 'rounded-l-control' : 'rounded-r-control'"
845
+ >
846
+ <span class="h-5 w-0.5 rounded-pill bg-white/85" />
847
+ </span>
848
+ </div>
849
+ <div
850
+ v-show="ready && time >= view.start && time <= view.end"
851
+ class="absolute -top-4 bottom-0 z-20 w-0 outline-none"
852
+ :style="{ left: pct(time) }"
853
+ role="slider"
854
+ :tabindex="off ? -1 : 0"
855
+ :aria-label="text.position"
856
+ :aria-valuemin="0"
857
+ :aria-valuemax="Math.round(duration * 1000) / 1000"
858
+ :aria-valuenow="Math.round(time * 1000) / 1000"
859
+ :aria-valuetext="handleText(time)"
860
+ data-lp-playhead
861
+ >
862
+ <span class="pointer-events-none absolute inset-y-0 -left-px w-0.5 bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.45)]" />
863
+ <span class="pointer-events-none absolute -top-0.5 -left-1.5 size-3 rounded-pill border-2 border-white bg-ink shadow [[data-lp-playhead]:focus-visible_&]:ring-2 [[data-lp-playhead]:focus-visible_&]:ring-ring" />
864
+ </div>
865
+ </div>
866
+ <!-- The whole video, for finding the way back while zoomed in. -->
867
+ <div
868
+ v-if="zoomed"
869
+ class="relative mt-1.5 h-2 cursor-pointer touch-none rounded-pill bg-surface-soft"
870
+ :aria-label="text.overview"
871
+ role="presentation"
872
+ data-lp-overview
873
+ @pointerdown="onOverviewDown"
874
+ >
875
+ <span class="absolute inset-y-0 rounded-pill bg-brand/45" :style="{ left: `${(local[0] / duration) * 100}%`, width: `${(clipLength / duration) * 100}%` }" />
876
+ <span class="absolute -inset-y-0.5 rounded-pill border-2 border-ink/70" :style="{ left: `${(view.start / duration) * 100}%`, width: `${(span / duration) * 100}%` }" />
877
+ </div>
878
+ </div>
879
+
880
+ <!-- Exact times, typed. -->
881
+ <div class="flex flex-wrap items-end gap-x-3 gap-y-2 text-xs">
882
+ <label v-for="end in ([0, 1] as const)" :key="end" class="flex flex-col gap-1">
883
+ <span class="text-muted">{{ end === 0 ? text.start : text.end }}</span>
884
+ <LpInput
885
+ size="sm"
886
+ class="w-28 font-mono tabular-nums"
887
+ :model-value="fieldText(end)"
888
+ :disabled="off"
889
+ :invalid="invalid[end]"
890
+ :title="invalid[end] ? text.invalidTime : undefined"
891
+ inputmode="decimal"
892
+ spellcheck="false"
893
+ autocomplete="off"
894
+ :data-lp-time="end"
895
+ @update:model-value="onFieldInput(end, $event)"
896
+ @blur="commitField(end)"
897
+ @keydown.enter.prevent="commitField(end)"
898
+ @keydown.esc="revertField(end)"
899
+ />
900
+ </label>
901
+ <div class="flex flex-col gap-1 pb-1.5">
902
+ <span class="text-muted">{{ text.length }}</span>
903
+ <span class="font-mono text-sm text-ink tabular-nums" data-lp-length>{{ formatClipTime(clipLength, true) }}</span>
904
+ </div>
905
+ <span v-if="leftText" class="pb-2 text-muted" data-lp-left>{{ leftText }}</span>
906
+ <span class="flex-1" />
907
+ <template v-if="regionSelect">
908
+ <LpButton v-if="region" size="sm" variant="outline" :disabled="disabled" @click="emit('update:region', null)">
909
+ <LpIcon name="lucide:x" :size="14" />{{ text.regionClear }}
910
+ </LpButton>
911
+ <span v-else class="pb-2 text-muted">{{ text.regionHint }}</span>
912
+ </template>
913
+ </div>
914
+ </div>
915
+ </template>
916
+
917
+ <style scoped>
918
+ .lp-trim-still-in > :deep(canvas) {
919
+ animation: lp-trim-still var(--duration-medium) var(--ease-emphasized);
920
+ }
921
+ @keyframes lp-trim-still {
922
+ from { opacity: 0; }
923
+ }
924
+ </style>