@leavepulse/ui 0.81.0 → 0.83.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.
- package/dist/assets/LpVideoTrim-BcGs3Sh0.css +8 -0
- package/dist/assets/VideoFlash-VuPkC7QV.css +23 -0
- package/dist/chunks/{ContextMenuItems-DLK4q-mm.js → ContextMenuItems-h-apPY2d.js} +1 -1
- package/dist/chunks/{DropzoneLinkField-CcJu8De3.js → DropzoneLinkField-_Op8b_ee.js} +1 -1
- package/dist/chunks/{FieldLeading-CldGLINn.js → FieldLeading-mOMcwNqi.js} +1 -1
- package/dist/chunks/{FieldWrapper-Cj8ZgoKp.js → FieldWrapper-DtT-AIcn.js} +1 -1
- package/dist/chunks/{IconCloseButton-CV4iHRpy.js → IconCloseButton-YmisskyZ.js} +1 -1
- package/dist/chunks/{LayoutCanvas-BQbq7Ekw.js → LayoutCanvas-BrISZEBr.js} +1 -1
- package/dist/chunks/{LayoutNode-BMedRk3g.js → LayoutNode-CwMQ8ACa.js} +1 -1
- package/dist/chunks/{LpActionBar-C7g52Cyo.js → LpActionBar-CVVC6YbC.js} +1 -1
- package/dist/chunks/{LpAddressInput-1uakhQk5.js → LpAddressInput-tiuJ-bru.js} +7 -7
- package/dist/chunks/{LpAlert-DWFeR82L.js → LpAlert-8-x4H_Nk.js} +1 -1
- package/dist/chunks/{LpAppShell-BBINfN1v.js → LpAppShell-BW2YMq-A.js} +4 -4
- package/dist/chunks/{LpAutocomplete-DY3LvpB9.js → LpAutocomplete-BZODIops.js} +4 -4
- package/dist/chunks/{LpAvatar-CEZmMcGh.js → LpAvatar-BZq4nH53.js} +1 -1
- package/dist/chunks/{LpBadge-C_VqLHtg.js → LpBadge-Cz0pSSUu.js} +1 -1
- package/dist/chunks/{LpBottomNav-4I89sPDZ.js → LpBottomNav-DVdR5Mry.js} +1 -1
- package/dist/chunks/{LpBreadcrumbs-1BBzrfxG.js → LpBreadcrumbs-B2jphAel.js} +1 -1
- package/dist/chunks/{LpCalendar-D2dcXjUK.js → LpCalendar-C0_N5LVT.js} +1 -1
- package/dist/chunks/{LpCheckbox-CN-MEnbX.js → LpCheckbox-CwqLNdw4.js} +2 -2
- package/dist/chunks/{LpCodeBlock-Cj7zk4QE.js → LpCodeBlock-Dcss6bnl.js} +2 -2
- package/dist/chunks/{LpCodeDisplay-COzosm77.js → LpCodeDisplay-CLms1zDY.js} +4 -4
- package/dist/chunks/{LpColorPicker-BezW6DX1.js → LpColorPicker-CbIY450X.js} +3 -3
- package/dist/chunks/{LpConfirmDialog-D9GkDMYr.js → LpConfirmDialog-RmP4z6va.js} +2 -2
- package/dist/chunks/{LpConfirmHost-BH7kf0dm.js → LpConfirmHost-DheBgRMB.js} +1 -1
- package/dist/chunks/{LpContextMenu-bn5bc9t7.js → LpContextMenu-DIYhlap8.js} +2 -2
- package/dist/chunks/{LpContextSwitcher-CGr6ZAuq.js → LpContextSwitcher-fDA6ERl-.js} +2 -2
- package/dist/chunks/{LpCopyButton-BSr6YaFR.js → LpCopyButton-DSqKFGEb.js} +1 -1
- package/dist/chunks/{LpDataList-Cv7ZUlyy.js → LpDataList-f9UjSx9m.js} +2 -2
- package/dist/chunks/{LpDatePicker-CR7vsb5M.js → LpDatePicker-3kDQLVbg.js} +5 -5
- package/dist/chunks/{LpDateRangePicker-B9fRO3G6.js → LpDateRangePicker-DUPiTt0_.js} +3 -3
- package/dist/chunks/{LpDisclosure-CE4mneaE.js → LpDisclosure-C5WZjhbQ.js} +1 -1
- package/dist/chunks/{LpDrawer-DU_5MqBz.js → LpDrawer-CkYvIGIa.js} +1 -1
- package/dist/chunks/{LpDropdownMenu-_u6GPWhp.js → LpDropdownMenu-zSsK2Xra.js} +6 -3
- package/dist/chunks/{LpEditable-DYoMayjt.js → LpEditable-CP7Y_lKg.js} +2 -2
- package/dist/chunks/{LpEmptyState-DpdHeSfz.js → LpEmptyState-dsc5x-ya.js} +1 -1
- package/dist/chunks/{LpFieldNotice-DONFCWV7.js → LpFieldNotice-TOTbWvgv.js} +1 -1
- package/dist/chunks/{LpFileTree-f5ktGqsN.js → LpFileTree-BPjnOTln.js} +4 -4
- package/dist/chunks/{LpFileTreeNode-nbp-JwV9.js → LpFileTreeNode-_-MiWmie.js} +4 -4
- package/dist/chunks/{LpFormField-Bz50_LZW.js → LpFormField-BJwD1iEF.js} +1 -1
- package/dist/chunks/{LpHintLayer-B6bA5Cb_.js → LpHintLayer-CmfGnxYu.js} +1 -1
- package/dist/chunks/{LpHints-DyJ5hVcr.js → LpHints-C2Xvc1Fd.js} +3 -3
- package/dist/chunks/{LpIcon-BCQLuHSP.js → LpIcon-CoOLz1oJ.js} +1 -1
- package/dist/chunks/{LpInfraNode-BS1RXYAs.js → LpInfraNode-CW3qsM-U.js} +2 -2
- package/dist/chunks/{LpInput-BjbXdjcW.js → LpInput-BVWhkPuN.js} +3 -3
- package/dist/chunks/{LpInspector-BIyJyM6r.js → LpInspector-REmoX_es.js} +4 -4
- package/dist/chunks/{LpLightbox-CSAJuUaJ.js → LpLightbox-C3FFTYP5.js} +65 -20
- package/dist/chunks/{LpLogInspector-OJEz1vod.js → LpLogInspector-Cs8Z_fM0.js} +2 -2
- package/dist/chunks/{LpLogViewer-CRLwQpk0.js → LpLogViewer-DQRGe-ZK.js} +6 -6
- package/dist/chunks/{LpMapPicker-DW2EnrCJ.js → LpMapPicker-CQnQPgKm.js} +1 -1
- package/dist/chunks/{LpMediaGallery-BNEYctDP.js → LpMediaGallery-DkKqTy9P.js} +20 -8
- package/dist/chunks/{LpModal-DdTqdf4x.js → LpModal-DwejR7VP.js} +1 -1
- package/dist/chunks/{LpModalNav-BL7Dp0om.js → LpModalNav-D1-dSe6f.js} +3 -3
- package/dist/chunks/{LpNotificationBell-d0F6MWxD.js → LpNotificationBell-EKLkN0sp.js} +4 -4
- package/dist/chunks/{LpNotificationFeed-BIe5lapb.js → LpNotificationFeed-Dc-yQ4NH.js} +4 -4
- package/dist/chunks/{LpNumberField-D5Dx-hzD.js → LpNumberField-DvUjEMp-.js} +3 -3
- package/dist/chunks/{LpOtpInput-BVl74Lfl.js → LpOtpInput-xvDO14f4.js} +1 -1
- package/dist/chunks/{LpPagination-D0wb-Uru.js → LpPagination-BFZY8dFn.js} +1 -1
- package/dist/chunks/{LpPaletteTheme-DdilCBXA.js → LpPaletteTheme-EBJv0Bwz.js} +2 -2
- package/dist/chunks/{LpPasswordInput-DUtqWMkA.js → LpPasswordInput-DRJR3fF1.js} +2 -2
- package/dist/chunks/{LpPhoneInput-CalRjkqc.js → LpPhoneInput-BGOOtUoW.js} +3 -3
- package/dist/chunks/{LpPie-BVAEmVBT.js → LpPie-Deyn8h5J.js} +2 -2
- package/dist/chunks/{LpPipeline-Dk7_sRP1.js → LpPipeline-9ChjeNh5.js} +2 -2
- package/dist/chunks/{LpQuota-DKx0IZYA.js → LpQuota-CNh5eF_p.js} +1 -1
- package/dist/chunks/{LpRadioGroup-E3SP6j9h.js → LpRadioGroup-Br2Nl9Gr.js} +2 -2
- package/dist/chunks/{LpRangeCalendar-BFJFaen4.js → LpRangeCalendar-D66Y3U_w.js} +1 -1
- package/dist/chunks/{LpRangeSlider-b6ipywRB.js → LpRangeSlider-eX-fIlOw.js} +1 -1
- package/dist/chunks/{LpRepeater-BLrw62Bh.js → LpRepeater-0XvnlVd_.js} +2 -2
- package/dist/chunks/{LpSecretField-BV8BNjKc.js → LpSecretField-1RCooChq.js} +5 -5
- package/dist/chunks/{LpSection-CghNNxIv.js → LpSection-B9gbJJcP.js} +1 -1
- package/dist/chunks/{LpSegmented-DjvrHL_G.js → LpSegmented-hIjGzGTo.js} +2 -2
- package/dist/chunks/{LpSelect-DYPimzAZ.js → LpSelect-DugubwTc.js} +4 -4
- package/dist/chunks/{LpSidebar-D3S8rYFf.js → LpSidebar-DevSoN8W.js} +4 -4
- package/dist/chunks/{LpSidebarNav-Dlse8vxT.js → LpSidebarNav-DwQQ3KXe.js} +3 -3
- package/dist/chunks/{LpSidebarRow-Cqd_6tGG.js → LpSidebarRow-CAGgtJCS.js} +3 -3
- package/dist/chunks/{LpSlider-B9XIC75O.js → LpSlider-1fHpVxpB.js} +1 -1
- package/dist/chunks/{LpStat-kyLRmX-m.js → LpStat-Cm9-FaWQ.js} +2 -2
- package/dist/chunks/{LpStatus-szB2dWzM.js → LpStatus-CE8FSAT1.js} +1 -1
- package/dist/chunks/{LpStepMark-DP3CDSm9.js → LpStepMark-BWSlV69R.js} +1 -1
- package/dist/chunks/{LpStepper-BS3iaJIz.js → LpStepper-1D0ZA7Nx.js} +1 -1
- package/dist/chunks/{LpSwitch-DRFy8RNr.js → LpSwitch-D_kvE0oQ.js} +1 -1
- package/dist/chunks/{LpTabFace-C9-OperL.js → LpTabFace-CpbFKi-N.js} +2 -2
- package/dist/chunks/{LpTableFilter-CEIwLupk.js → LpTableFilter-BYHyCu38.js} +2 -2
- package/dist/chunks/{LpTagsInput-ClDeF_aU.js → LpTagsInput-Cqm38t5N.js} +5 -5
- package/dist/chunks/{LpTextarea-DEY6pUBy.js → LpTextarea-DaSKBkiV.js} +1 -1
- package/dist/chunks/{LpThemeStudio-WY72Z1Db.js → LpThemeStudio-BNdfi-Ds.js} +5 -5
- package/dist/chunks/{LpThemeSwitcher-9uCH9c-K.js → LpThemeSwitcher-CbC1nqBP.js} +3 -3
- package/dist/chunks/{LpTimeField-BmgxCzQO.js → LpTimeField-CCfPCWPT.js} +2 -2
- package/dist/chunks/{LpToaster-DLKO-efn.js → LpToaster-BhlI7h-h.js} +2 -2
- package/dist/chunks/{LpTopologyCanvas-B-PhPa7k.js → LpTopologyCanvas-DlKbu7E5.js} +4 -4
- package/dist/chunks/{LpTour-ODlBcat2.js → LpTour-CSLerA1B.js} +1 -1
- package/dist/chunks/{LpTruncate--nhT0iUo.js → LpTruncate-Ct26BSYB.js} +1 -1
- package/dist/chunks/{LpVideoBackdrop-Ct4St4ZL.js → LpVideoBackdrop-57i8RY8g.js} +1 -2
- package/dist/chunks/LpVideoPlayer-DcWd9DDW.js +813 -0
- package/dist/chunks/{LpWindow-DJxE8Zl-.js → LpWindow-YnF_knSj.js} +2 -2
- package/dist/chunks/{LpWorkspaceSwitcher-Bpf41Bw5.js → LpWorkspaceSwitcher-B-5UU6o4.js} +3 -3
- package/dist/chunks/{MenuTitle-CMjvLsvC.js → MenuTitle-BnZbFSwq.js} +1 -1
- package/dist/chunks/{OptionContent-DY8NaG8s.js → OptionContent-D5e_eklA.js} +1 -1
- package/dist/chunks/{PickerField-CgfkaBgg.js → PickerField-KpHiowNI.js} +1 -1
- package/dist/chunks/{RevealToggle-CPMXxaKv.js → RevealToggle-CE1hAIJB.js} +1 -1
- package/dist/chunks/SteadyText-CwH-kduj.js +25 -0
- package/dist/chunks/{TourCard-D4b3lMHi.js → TourCard-t2csiTDS.js} +1 -1
- package/dist/chunks/VideoThumb-Csf9bUz3.js +67 -0
- package/dist/chunks/VideoVolume-B4_DNmNi.js +68 -0
- package/dist/chunks/icons-CdSlCrle.js +59 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/ContextMenuItems.vue.js +1 -1
- package/dist/components/DropzoneLinkField.vue.js +1 -1
- package/dist/components/FieldLeading.vue.js +1 -1
- package/dist/components/FieldWrapper.vue.js +1 -1
- package/dist/components/IconCloseButton.vue.js +1 -1
- package/dist/components/LayoutCanvas.vue.js +1 -1
- package/dist/components/LayoutNode.vue.js +1 -1
- package/dist/components/LpActionBar.vue.js +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAlert.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpAutocomplete.vue.js +1 -1
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpBadge.vue.js +1 -1
- package/dist/components/LpBottomNav.vue.js +1 -1
- package/dist/components/LpBreadcrumbs.vue.js +1 -1
- package/dist/components/LpCalendar.vue.js +1 -1
- package/dist/components/LpChart.vue.js +2 -2
- package/dist/components/LpCheckbox.vue.js +1 -1
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpCodeDisplay.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.js +2 -2
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpConfirmHost.vue.js +1 -1
- package/dist/components/LpContextMenu.vue.js +1 -1
- package/dist/components/LpContextSwitcher.vue.js +1 -1
- package/dist/components/LpCopyButton.vue.js +1 -1
- package/dist/components/LpDataList.vue.js +1 -1
- package/dist/components/LpDatePicker.vue.js +1 -1
- package/dist/components/LpDateRangePicker.vue.js +1 -1
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpDock.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropdownMenu.vue.d.ts +5 -1
- package/dist/components/LpDropdownMenu.vue.js +1 -1
- package/dist/components/LpDropzone.vue.js +2 -2
- package/dist/components/LpEditable.vue.js +1 -1
- package/dist/components/LpEmptyState.vue.js +1 -1
- package/dist/components/LpFieldNotice.vue.js +1 -1
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpFormField.vue.js +1 -1
- package/dist/components/LpHintLayer.vue.js +1 -1
- package/dist/components/LpHints.vue.js +1 -1
- package/dist/components/LpIcon.vue.js +1 -1
- package/dist/components/LpImageEditor.vue.js +1 -1
- package/dist/components/LpInfraNode.vue.js +1 -1
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpInspector.vue.js +1 -1
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpLogInspector.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.d.ts +1 -1
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpNotificationFeed.vue.js +1 -1
- package/dist/components/LpNumberField.vue.js +1 -1
- package/dist/components/LpOtpInput.vue.js +1 -1
- package/dist/components/LpPagination.vue.js +1 -1
- package/dist/components/LpPaletteFacts.vue.js +1 -1
- package/dist/components/LpPaletteHeader.vue.js +2 -2
- package/dist/components/LpPaletteTheme.vue.js +1 -1
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpPhoneInput.vue.js +1 -1
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpPipeline.vue.js +1 -1
- package/dist/components/LpQuota.vue.js +1 -1
- package/dist/components/LpRadioGroup.vue.js +1 -1
- package/dist/components/LpRangeCalendar.vue.js +1 -1
- package/dist/components/LpRangeSlider.vue.js +1 -1
- package/dist/components/LpRepeater.vue.js +1 -1
- package/dist/components/LpSecretField.vue.js +1 -1
- package/dist/components/LpSection.vue.js +1 -1
- package/dist/components/LpSegmented.vue.js +1 -1
- package/dist/components/LpSelect.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.js +1 -1
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpSidebarRow.vue.js +1 -1
- package/dist/components/LpSlider.vue.js +1 -1
- package/dist/components/LpStat.vue.js +1 -1
- package/dist/components/LpStatus.vue.js +1 -1
- package/dist/components/LpStepMark.vue.js +1 -1
- package/dist/components/LpStepper.vue.js +1 -1
- package/dist/components/LpSwitch.vue.js +1 -1
- package/dist/components/LpTabFace.vue.js +1 -1
- package/dist/components/LpTable.vue.js +9 -9
- package/dist/components/LpTableFilter.vue.js +1 -1
- package/dist/components/LpTabs.vue.js +2 -2
- package/dist/components/LpTagsInput.vue.js +1 -1
- package/dist/components/LpTextarea.vue.js +1 -1
- package/dist/components/LpThemeStudio.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTimeField.vue.js +1 -1
- package/dist/components/LpToaster.vue.js +1 -1
- package/dist/components/LpTopologyCanvas.vue.js +1 -1
- package/dist/components/LpTour.vue.js +1 -1
- package/dist/components/LpTruncate.vue.js +1 -1
- package/dist/components/LpVideoBackdrop.vue.js +1 -1
- package/dist/components/LpVideoPlayer.vue.d.ts +111 -0
- package/dist/components/LpVideoPlayer.vue.js +4 -0
- package/dist/components/LpVideoTrim.vue.d.ts +82 -0
- package/dist/components/LpVideoTrim.vue.js +1106 -0
- package/dist/components/LpWindow.vue.js +1 -1
- package/dist/components/LpWorkspaceSwitcher.vue.js +1 -1
- package/dist/components/MenuTitle.vue.js +1 -1
- package/dist/components/OptionContent.vue.js +1 -1
- package/dist/components/PickerField.vue.js +1 -1
- package/dist/components/RevealToggle.vue.js +1 -1
- package/dist/components/SteadyText.vue.d.ts +7 -0
- package/dist/components/SteadyText.vue.js +4 -0
- package/dist/components/TourCard.vue.js +1 -1
- package/dist/components/VideoFlash.vue.d.ts +10 -0
- package/dist/components/VideoFlash.vue.js +33 -0
- package/dist/components/VideoThumb.vue.d.ts +12 -0
- package/dist/components/VideoThumb.vue.js +4 -0
- package/dist/components/VideoVolume.vue.d.ts +15 -0
- package/dist/components/VideoVolume.vue.js +4 -0
- package/dist/components/chartWindow.js +4 -8
- package/dist/components/lightbox.d.ts +13 -0
- package/dist/components/lightbox.js +11 -1
- package/dist/components/menuWhen.js +1 -1
- package/dist/components/videoBackdrop.d.ts +15 -2
- package/dist/components/videoBackdrop.js +8 -2
- package/dist/components/videoFrames.d.ts +37 -0
- package/dist/components/videoFrames.js +134 -0
- package/dist/components/videoMotion.d.ts +28 -0
- package/dist/components/videoMotion.js +26 -0
- package/dist/components/videoTrim.d.ts +68 -0
- package/dist/components/videoTrim.js +121 -0
- package/dist/composables/span.d.ts +10 -0
- package/dist/composables/span.js +15 -0
- package/dist/composables/useVideoMedia.d.ts +74 -0
- package/dist/composables/useVideoMedia.js +291 -0
- package/dist/icons-all.js +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +186 -174
- package/package.json +1 -1
- package/src/component-names.ts +2 -0
- package/src/components/LpDropdownMenu.vue +4 -0
- package/src/components/LpLightbox.vue +61 -8
- package/src/components/LpMediaGallery.vue +19 -2
- package/src/components/LpVideoBackdrop.vue +1 -7
- package/src/components/LpVideoPlayer.vue +774 -0
- package/src/components/LpVideoTrim.vue +1066 -0
- package/src/components/SteadyText.vue +21 -0
- package/src/components/VideoFlash.vue +52 -0
- package/src/components/VideoThumb.vue +66 -0
- package/src/components/VideoVolume.vue +63 -0
- package/src/components/chartWindow.ts +4 -8
- package/src/components/lightbox.ts +23 -0
- package/src/components/videoBackdrop.ts +20 -3
- package/src/components/videoFrames.ts +171 -0
- package/src/components/videoMotion.ts +38 -0
- package/src/components/videoTrim.ts +186 -0
- package/src/composables/span.ts +21 -0
- package/src/composables/useVideoMedia.ts +411 -0
- package/src/index.ts +7 -1
- package/src/kit-icons.ts +1 -1
- package/dist/chunks/icons-C1vppR4f.js +0 -59
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
/** Why the editor has no video to show; see `VideoErrorCause`. */
|
|
186
|
+
export type { VideoErrorCause as VideoTrimErrorCause } from "../composables/useVideoMedia"
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { onBeforeUnmount, ref, watch, type Ref } from "vue"
|
|
2
|
+
import { SLOWEST_RATE } from "../components/videoBackdrop"
|
|
3
|
+
import { nodeWindow } from "./realm"
|
|
4
|
+
import { usePersisted } from "./usePersisted"
|
|
5
|
+
import { usePrefersReducedMotion } from "./useReducedMotion"
|
|
6
|
+
import { useTween } from "./useTween"
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* The state of a <video> as a component that draws its own controls needs it,
|
|
10
|
+
* shared by LpVideoPlayer and LpVideoTrim so both know the same things about
|
|
11
|
+
* a video the same way:
|
|
12
|
+
*
|
|
13
|
+
* - One seek at a time, the latest wins: a drag asks for a position on every
|
|
14
|
+
* pointer move, and WebKitGTK's pipeline stalls on a seek started inside
|
|
15
|
+
* another.
|
|
16
|
+
* - A MediaRecorder WebM carries no duration until its end has been seen: it
|
|
17
|
+
* is found by seeking far past it once.
|
|
18
|
+
* - Chromium answers a 404, or a login page served in place of an expired
|
|
19
|
+
* session's video, with "format not supported", the same code as a real
|
|
20
|
+
* codec problem; one byte of the address tells the two apart.
|
|
21
|
+
* - The playhead follows the picture every frame while playing, not the four
|
|
22
|
+
* `timeupdate`s a second.
|
|
23
|
+
*
|
|
24
|
+
* Bind `listeners` on the element with `v-on`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Why there is no video to show:
|
|
29
|
+
* - "load": the address did not answer (network, 404, an expired session);
|
|
30
|
+
* - "decode": the file arrived but is damaged;
|
|
31
|
+
* - "unsupported": a format or codec this browser cannot play (H.265 in
|
|
32
|
+
* Chromium without hardware support, ProRes anywhere).
|
|
33
|
+
*/
|
|
34
|
+
export type VideoErrorCause = "load" | "decode" | "unsupported"
|
|
35
|
+
|
|
36
|
+
export function mediaErrorCause(error: MediaError | null): VideoErrorCause {
|
|
37
|
+
if (error?.code === 3) return "decode"
|
|
38
|
+
if (error?.code === 4) return "unsupported"
|
|
39
|
+
return "load"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UseVideoMediaOptions {
|
|
43
|
+
src: () => string
|
|
44
|
+
/** Every frame while playing, after `time` is updated. */
|
|
45
|
+
onFrame?: () => void
|
|
46
|
+
onError?: (cause: VideoErrorCause) => void
|
|
47
|
+
/** Ease starts and stops (true by default); false cuts in and out at once. */
|
|
48
|
+
ease?: () => boolean
|
|
49
|
+
/**
|
|
50
|
+
* Keep volume, mute and speed between videos on this device, shared by every
|
|
51
|
+
* player and editor of the kit: someone who turned the sound down turned it
|
|
52
|
+
* down, whichever component the next video is in. False forgets.
|
|
53
|
+
*/
|
|
54
|
+
remember?: () => boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Sound {
|
|
58
|
+
volume: number
|
|
59
|
+
muted: boolean
|
|
60
|
+
rate: number
|
|
61
|
+
}
|
|
62
|
+
/*
|
|
63
|
+
* A kept record is used only when it is one a person could have chosen: a
|
|
64
|
+
* speed outside what the menus offer, or a volume outside 0..1, is a record
|
|
65
|
+
* written by an easing caught mid-way (a sixteenth speed and silence, once),
|
|
66
|
+
* and restoring it would start every video stuck.
|
|
67
|
+
*/
|
|
68
|
+
const isSound = (v: unknown): v is Sound => {
|
|
69
|
+
if (!v || typeof v !== "object") return false
|
|
70
|
+
const { volume, muted, rate } = v as Sound
|
|
71
|
+
return typeof muted === "boolean" && typeof volume === "number" && volume >= 0 && volume <= 1 && typeof rate === "number" && rate >= 0.25 && rate <= 4
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useVideoMedia(el: Ref<HTMLVideoElement | null>, options: UseVideoMediaOptions) {
|
|
75
|
+
const duration = ref(0)
|
|
76
|
+
const time = ref(0)
|
|
77
|
+
const playing = ref(false)
|
|
78
|
+
/** Playback stalled for data. */
|
|
79
|
+
const waiting = ref(false)
|
|
80
|
+
const ended = ref(false)
|
|
81
|
+
const muted = ref(false)
|
|
82
|
+
const volume = ref(1)
|
|
83
|
+
const rate = ref(1)
|
|
84
|
+
/** Buffered stretches as fractions of the duration. */
|
|
85
|
+
const buffered = ref<[number, number][]>([])
|
|
86
|
+
const failure = ref<VideoErrorCause | null>(null)
|
|
87
|
+
const size = ref({ width: 0, height: 0 })
|
|
88
|
+
|
|
89
|
+
const sound = usePersisted<Sound>(() => (options.remember?.() ?? true) && "lp-video:sound", { volume: 1, muted: false, rate: 1 }, { validate: isSound })
|
|
90
|
+
/* Applied once per address, when it first knows its duration; saved only after, so a fresh element's defaults never overwrite what was kept. */
|
|
91
|
+
let applied = ""
|
|
92
|
+
|
|
93
|
+
let seekBusy = false
|
|
94
|
+
let seekTarget: number | null = null
|
|
95
|
+
let probingDuration = false
|
|
96
|
+
let frame = 0
|
|
97
|
+
|
|
98
|
+
function seek(t: number) {
|
|
99
|
+
const video = el.value
|
|
100
|
+
const target = Math.min(Math.max(0, t), duration.value || t)
|
|
101
|
+
time.value = target
|
|
102
|
+
ended.value = false
|
|
103
|
+
if (!video) return
|
|
104
|
+
if (seekBusy) {
|
|
105
|
+
seekTarget = target
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
seekBusy = true
|
|
109
|
+
video.currentTime = target
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/*
|
|
113
|
+
* `currentTime` moves in steps, one per decoded frame: at a few frames a
|
|
114
|
+
* second (a screen recording, a slideshow) a bar or a playhead driven by it
|
|
115
|
+
* jumps along. Between steps the position runs on the clock from the last
|
|
116
|
+
* one, at the playback rate; it never goes back (a step that lands a hair
|
|
117
|
+
* behind the estimate is held), and never runs more than a moment ahead of
|
|
118
|
+
* the video, so a stall shows as a stop, not as a bar that keeps going.
|
|
119
|
+
*/
|
|
120
|
+
const AHEAD_LIMIT = 0.25
|
|
121
|
+
let lastMedia = -1
|
|
122
|
+
let lastAt = 0
|
|
123
|
+
function smoothTime(video: HTMLVideoElement, now: number): number {
|
|
124
|
+
const t = video.currentTime
|
|
125
|
+
if (t !== lastMedia) {
|
|
126
|
+
lastMedia = t
|
|
127
|
+
lastAt = now
|
|
128
|
+
}
|
|
129
|
+
const estimate = Math.min(lastMedia + ((now - lastAt) / 1000) * video.playbackRate, lastMedia + AHEAD_LIMIT, duration.value || Infinity)
|
|
130
|
+
return Math.max(estimate, Math.min(time.value, lastMedia + AHEAD_LIMIT))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function tick(now: number) {
|
|
134
|
+
frame = 0
|
|
135
|
+
const video = el.value
|
|
136
|
+
if (!video || !playing.value) return
|
|
137
|
+
if (!seekBusy) time.value = smoothTime(video, now)
|
|
138
|
+
options.onFrame?.()
|
|
139
|
+
if (playing.value) frame = nodeWindow(video).requestAnimationFrame(tick)
|
|
140
|
+
}
|
|
141
|
+
function stopTicking() {
|
|
142
|
+
if (frame && el.value) nodeWindow(el.value).cancelAnimationFrame(frame)
|
|
143
|
+
frame = 0
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readBuffered(video: HTMLVideoElement) {
|
|
147
|
+
const d = duration.value
|
|
148
|
+
if (!d) return
|
|
149
|
+
const out: [number, number][] = []
|
|
150
|
+
for (let i = 0; i < video.buffered.length; i++) out.push([video.buffered.start(i) / d, video.buffered.end(i) / d])
|
|
151
|
+
buffered.value = out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function probe(src: string): Promise<VideoErrorCause> {
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetch(src, { headers: { Range: "bytes=0-0" }, credentials: "same-origin" })
|
|
157
|
+
void res.body?.cancel()
|
|
158
|
+
const type = res.headers.get("content-type") ?? ""
|
|
159
|
+
return res.ok && !/^text\/|json|xml/.test(type) ? "unsupported" : "load"
|
|
160
|
+
} catch {
|
|
161
|
+
// Another origin without CORS: there is no telling, so the browser's word stands.
|
|
162
|
+
return "unsupported"
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const listeners = {
|
|
167
|
+
loadedmetadata() {
|
|
168
|
+
const video = el.value
|
|
169
|
+
if (!video) return
|
|
170
|
+
size.value = { width: video.videoWidth, height: video.videoHeight }
|
|
171
|
+
if (applied !== options.src()) {
|
|
172
|
+
applied = options.src()
|
|
173
|
+
volume.value = sound.value.volume
|
|
174
|
+
rate.value = sound.value.rate
|
|
175
|
+
video.muted = video.muted || sound.value.muted
|
|
176
|
+
muted.value = video.muted
|
|
177
|
+
video.volume = volume.value
|
|
178
|
+
video.playbackRate = rate.value
|
|
179
|
+
}
|
|
180
|
+
if (Number.isFinite(video.duration)) {
|
|
181
|
+
duration.value = video.duration
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
probingDuration = true
|
|
185
|
+
video.currentTime = 1e101
|
|
186
|
+
},
|
|
187
|
+
durationchange() {
|
|
188
|
+
const video = el.value
|
|
189
|
+
if (!video || !Number.isFinite(video.duration) || !video.duration) return
|
|
190
|
+
duration.value = video.duration
|
|
191
|
+
if (probingDuration) {
|
|
192
|
+
probingDuration = false
|
|
193
|
+
video.currentTime = 0
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
seeked() {
|
|
197
|
+
seekBusy = false
|
|
198
|
+
lastMedia = -1
|
|
199
|
+
if (seekTarget !== null) {
|
|
200
|
+
const next = seekTarget
|
|
201
|
+
seekTarget = null
|
|
202
|
+
seek(next)
|
|
203
|
+
} else if (el.value && !playing.value) time.value = el.value.currentTime
|
|
204
|
+
},
|
|
205
|
+
play() {
|
|
206
|
+
lastMedia = -1
|
|
207
|
+
// Started from outside (media keys, the picture-in-picture window): it wants to play now.
|
|
208
|
+
wantsPlay.value = true
|
|
209
|
+
playing.value = true
|
|
210
|
+
ended.value = false
|
|
211
|
+
if (!frame && el.value) frame = nodeWindow(el.value).requestAnimationFrame(tick)
|
|
212
|
+
},
|
|
213
|
+
pause() {
|
|
214
|
+
wantsPlay.value = false
|
|
215
|
+
playing.value = false
|
|
216
|
+
stopTicking()
|
|
217
|
+
if (el.value && !seekBusy) time.value = el.value.currentTime
|
|
218
|
+
},
|
|
219
|
+
ended() {
|
|
220
|
+
instant = true
|
|
221
|
+
wantsPlay.value = false
|
|
222
|
+
ended.value = true
|
|
223
|
+
},
|
|
224
|
+
waiting() {
|
|
225
|
+
waiting.value = true
|
|
226
|
+
},
|
|
227
|
+
playing() {
|
|
228
|
+
waiting.value = false
|
|
229
|
+
},
|
|
230
|
+
canplay() {
|
|
231
|
+
waiting.value = false
|
|
232
|
+
},
|
|
233
|
+
timeupdate() {
|
|
234
|
+
if (el.value && !playing.value && !seekBusy) time.value = el.value.currentTime
|
|
235
|
+
},
|
|
236
|
+
progress() {
|
|
237
|
+
if (el.value) readBuffered(el.value)
|
|
238
|
+
},
|
|
239
|
+
volumechange() {
|
|
240
|
+
const video = el.value
|
|
241
|
+
if (!video) return
|
|
242
|
+
// Firefox reports itself muted below a quarter speed: during the easing that is the easing's.
|
|
243
|
+
if (!easing()) muted.value = video.muted
|
|
244
|
+
if (easing() || video.volume === written.volume) return
|
|
245
|
+
volume.value = video.volume
|
|
246
|
+
remember()
|
|
247
|
+
},
|
|
248
|
+
ratechange() {
|
|
249
|
+
const video = el.value
|
|
250
|
+
if (!video || easing() || video.playbackRate === written.rate) return
|
|
251
|
+
rate.value = video.playbackRate
|
|
252
|
+
remember()
|
|
253
|
+
},
|
|
254
|
+
async error() {
|
|
255
|
+
const video = el.value
|
|
256
|
+
if (!video?.error) return
|
|
257
|
+
const src = options.src()
|
|
258
|
+
let cause = mediaErrorCause(video.error)
|
|
259
|
+
if (cause === "unsupported") cause = await probe(src)
|
|
260
|
+
if (src !== options.src()) return
|
|
261
|
+
failure.value = cause
|
|
262
|
+
waiting.value = false
|
|
263
|
+
options.onError?.(cause)
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/*
|
|
268
|
+
* Start and stop are eased, as LpVideoBackdrop's `easePlayback` is: a play
|
|
269
|
+
* gathers speed and sound from a standstill over a moment, a pause slows and
|
|
270
|
+
* quietens to one, instead of the picture and the sound cutting in and out
|
|
271
|
+
* in a single frame (and the sound with a click). `wantsPlay` turns at once,
|
|
272
|
+
* so a button's icon answers the press; the element follows. A pause for a
|
|
273
|
+
* seek or a drag, or at a clip's end, is `instant`: there the exact frame
|
|
274
|
+
* matters more than the glide. Reduced motion has no easing at all.
|
|
275
|
+
*/
|
|
276
|
+
const wantsPlay = ref(false)
|
|
277
|
+
let instant = false
|
|
278
|
+
const reducedMotion = usePrefersReducedMotion()
|
|
279
|
+
const throttle = useTween<number>(
|
|
280
|
+
() => (wantsPlay.value ? 1 : 0),
|
|
281
|
+
(from, to, k) => from + (to - from) * k,
|
|
282
|
+
{ enabled: () => !instant && !reducedMotion.value && (options.ease?.() ?? true) },
|
|
283
|
+
)
|
|
284
|
+
/*
|
|
285
|
+
* Only around playback: a paused element keeps its real rate and volume.
|
|
286
|
+
* Firefox silences sound below a quarter speed and reports the element as
|
|
287
|
+
* muted meanwhile, so a paused video held at the slowest rate came up muted
|
|
288
|
+
* and would not be unmuted. While the rate eases, what the element reports
|
|
289
|
+
* about its sound is the easing's, not a choice, and is not taken as one.
|
|
290
|
+
*/
|
|
291
|
+
/* What the easing itself last wrote: the element's later report of the same value is an echo, not a choice. */
|
|
292
|
+
const written = { rate: Number.NaN, volume: Number.NaN }
|
|
293
|
+
function applyThrottle(video: HTMLVideoElement, k: number) {
|
|
294
|
+
written.rate = video.playbackRate = k >= 1 ? rate.value : Math.max(SLOWEST_RATE, rate.value * k)
|
|
295
|
+
written.volume = video.volume = k >= 1 ? volume.value : volume.value * k
|
|
296
|
+
}
|
|
297
|
+
watch(throttle, (k) => {
|
|
298
|
+
const video = el.value
|
|
299
|
+
if (!video) return
|
|
300
|
+
if (k === 0 && !wantsPlay.value) {
|
|
301
|
+
if (!video.paused) video.pause()
|
|
302
|
+
applyThrottle(video, 1)
|
|
303
|
+
instant = false
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
applyThrottle(video, k)
|
|
307
|
+
if (k === 1) instant = false
|
|
308
|
+
})
|
|
309
|
+
/*
|
|
310
|
+
* Whether the element's rate and volume are the easing's rather than the
|
|
311
|
+
* chosen ones: from the press of play until full speed, and from the press
|
|
312
|
+
* of pause until the stop. The element reports a change a moment after it
|
|
313
|
+
* is made, so the bounds are the intent, not the throttle alone: a start
|
|
314
|
+
* reported while the throttle still read 0 was taken for a choice of a
|
|
315
|
+
* sixteenth speed and silence.
|
|
316
|
+
*/
|
|
317
|
+
const easing = () => (wantsPlay.value ? throttle.value < 1 : throttle.value > 0)
|
|
318
|
+
|
|
319
|
+
function remember() {
|
|
320
|
+
if (applied) sound.value = { volume: volume.value, muted: muted.value, rate: rate.value }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Play, quietly: a browser that refuses (autoplay with sound) just leaves it paused. */
|
|
324
|
+
function play() {
|
|
325
|
+
const video = el.value
|
|
326
|
+
if (!video || failure.value) return
|
|
327
|
+
if (ended.value) seek(0)
|
|
328
|
+
wantsPlay.value = true
|
|
329
|
+
// From a standstill the first frame already plays slow and quiet, not at full speed for one frame.
|
|
330
|
+
if (!instant && !reducedMotion.value && (options.ease?.() ?? true) && throttle.value === 0) applyThrottle(video, 0)
|
|
331
|
+
void video.play().catch(() => {
|
|
332
|
+
wantsPlay.value = false
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
/** Pause, easing to a stop unless `instant` (a seek, a drag, a clip's end, where the frame must be exact). */
|
|
336
|
+
function pause(options: { instant?: boolean } = {}) {
|
|
337
|
+
if (options.instant) instant = true
|
|
338
|
+
wantsPlay.value = false
|
|
339
|
+
}
|
|
340
|
+
function toggle() {
|
|
341
|
+
if (wantsPlay.value) pause()
|
|
342
|
+
else play()
|
|
343
|
+
}
|
|
344
|
+
function setMuted(value: boolean) {
|
|
345
|
+
if (el.value) el.value.muted = value
|
|
346
|
+
muted.value = value
|
|
347
|
+
remember()
|
|
348
|
+
}
|
|
349
|
+
function setVolume(value: number) {
|
|
350
|
+
const v = Math.min(1, Math.max(0, value))
|
|
351
|
+
volume.value = v
|
|
352
|
+
if (el.value) {
|
|
353
|
+
el.value.volume = easing() ? v * throttle.value : v
|
|
354
|
+
// Turning the sound up is asking to hear it.
|
|
355
|
+
if (v > 0 && el.value.muted) el.value.muted = false
|
|
356
|
+
}
|
|
357
|
+
// Said now, not on the element's event: a slider bound to "muted ? 0 : volume" would spring back to 0 meanwhile.
|
|
358
|
+
if (v > 0) muted.value = false
|
|
359
|
+
remember()
|
|
360
|
+
}
|
|
361
|
+
function setRate(value: number) {
|
|
362
|
+
rate.value = value
|
|
363
|
+
if (el.value) el.value.playbackRate = easing() ? Math.max(SLOWEST_RATE, value * throttle.value) : value
|
|
364
|
+
remember()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The frame on screen as a PNG, for "copy frame". Null when there is none
|
|
369
|
+
* yet, or when the video came from another origin without CORS and the
|
|
370
|
+
* browser will not let it be read back.
|
|
371
|
+
*/
|
|
372
|
+
function captureFrame(): Promise<Blob | null> {
|
|
373
|
+
const video = el.value
|
|
374
|
+
if (!video?.videoWidth) return Promise.resolve(null)
|
|
375
|
+
const canvas = video.ownerDocument.createElement("canvas")
|
|
376
|
+
canvas.width = video.videoWidth
|
|
377
|
+
canvas.height = video.videoHeight
|
|
378
|
+
try {
|
|
379
|
+
canvas.getContext("2d")?.drawImage(video, 0, 0)
|
|
380
|
+
return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), "image/png"))
|
|
381
|
+
} catch {
|
|
382
|
+
return Promise.resolve(null)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
watch(options.src, () => {
|
|
387
|
+
duration.value = 0
|
|
388
|
+
time.value = 0
|
|
389
|
+
failure.value = null
|
|
390
|
+
buffered.value = []
|
|
391
|
+
ended.value = false
|
|
392
|
+
waiting.value = false
|
|
393
|
+
seekBusy = false
|
|
394
|
+
seekTarget = null
|
|
395
|
+
probingDuration = false
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
onBeforeUnmount(() => {
|
|
399
|
+
stopTicking()
|
|
400
|
+
const video = el.value
|
|
401
|
+
if (!video) return
|
|
402
|
+
// Stop the stream from fetching on: an element that is merely detached keeps its connection.
|
|
403
|
+
video.pause()
|
|
404
|
+
video.removeAttribute("src")
|
|
405
|
+
video.load()
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
return { duration, time, playing, wantsPlay, waiting, ended, muted, volume, rate, buffered, failure, size, listeners, seek, play, pause, toggle, setMuted, setVolume, setRate, captureFrame }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export type VideoMedia = ReturnType<typeof useVideoMedia>
|
package/src/index.ts
CHANGED
|
@@ -156,7 +156,7 @@ export { default as LpInput } from "./components/LpInput.vue"
|
|
|
156
156
|
export { default as LpLightbox } from "./components/LpLightbox.vue"
|
|
157
157
|
export type { LightboxLabels } from "./components/LpLightbox.vue"
|
|
158
158
|
export type { LightboxItem } from "./components/lightbox"
|
|
159
|
-
export { fileNameOf } from "./components/lightbox"
|
|
159
|
+
export { fileNameOf, isVideoItem } from "./components/lightbox"
|
|
160
160
|
export { default as LpLink } from "./components/LpLink.vue"
|
|
161
161
|
export { default as LpLogViewer } from "./components/LpLogViewer.vue"
|
|
162
162
|
export { default as LpLogInspector } from "./components/LpLogInspector.vue"
|
|
@@ -226,6 +226,12 @@ 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 { default as LpVideoPlayer } from "./components/LpVideoPlayer.vue"
|
|
231
|
+
export { useVideoMedia } from "./composables/useVideoMedia"
|
|
232
|
+
export type { VideoErrorCause, VideoMedia } from "./composables/useVideoMedia"
|
|
233
|
+
export { dragEnd, normalizeRange as normalizeTrimRange, parseClipTime, setEnd } from "./components/videoTrim"
|
|
234
|
+
export type { FrameRegion, TimeRange, VideoTrimErrorCause } from "./components/videoTrim"
|
|
229
235
|
export { default as LpSparkline } from "./components/LpSparkline.vue"
|
|
230
236
|
export { default as LpStat } from "./components/LpStat.vue"
|
|
231
237
|
export { default as LpStepper } from "./components/LpStepper.vue"
|