@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,1066 @@
|
|
|
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
|
+
* - `#actions` puts the app's buttons over the picture's top right corner (a
|
|
28
|
+
* bin, a download), with `buttonClass` to look like the editor's own.
|
|
29
|
+
*/
|
|
30
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
|
|
31
|
+
import { startPointerDrag } from "../composables/pointerDrag"
|
|
32
|
+
import { isHTMLElement, nodeWindow } from "../composables/realm"
|
|
33
|
+
import { useLabels } from "../composables/useLpConfig"
|
|
34
|
+
import { useMergedAttrs } from "../composables/useMergedClass"
|
|
35
|
+
import { useResizeObserver } from "../composables/useResizeObserver"
|
|
36
|
+
import { usePrefersReducedMotion } from "../composables/useReducedMotion"
|
|
37
|
+
import { useVideoMedia } from "../composables/useVideoMedia"
|
|
38
|
+
import LpButton from "./LpButton.vue"
|
|
39
|
+
import LpIcon from "./LpIcon.vue"
|
|
40
|
+
import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
41
|
+
import LpInput from "./LpInput.vue"
|
|
42
|
+
import SteadyText from "./SteadyText.vue"
|
|
43
|
+
import VideoFlash from "./VideoFlash.vue"
|
|
44
|
+
import VideoVolume from "./VideoVolume.vue"
|
|
45
|
+
import { FADE, FADE_SCALE, ICON_SWAP, OVERLAY_BUTTON } from "./videoMotion"
|
|
46
|
+
import { useClipboard } from "../composables/useClipboard"
|
|
47
|
+
import { useToast } from "../composables/useToast"
|
|
48
|
+
import { formatClipTime, widestClipTime } from "./videoBackdrop"
|
|
49
|
+
import { FrameSampler, stillSpan } from "./videoFrames"
|
|
50
|
+
import {
|
|
51
|
+
adjustRegion,
|
|
52
|
+
containRect,
|
|
53
|
+
dragEnd,
|
|
54
|
+
normalizeRange,
|
|
55
|
+
parseClipTime,
|
|
56
|
+
regionBetween,
|
|
57
|
+
revealTime,
|
|
58
|
+
setEnd,
|
|
59
|
+
shiftView,
|
|
60
|
+
tickStep,
|
|
61
|
+
ticks,
|
|
62
|
+
zoomView,
|
|
63
|
+
type FrameRegion,
|
|
64
|
+
type RegionHandle,
|
|
65
|
+
type TimeRange,
|
|
66
|
+
type TimeView,
|
|
67
|
+
type VideoTrimErrorCause,
|
|
68
|
+
} from "./videoTrim"
|
|
69
|
+
|
|
70
|
+
const props = withDefaults(
|
|
71
|
+
defineProps<{
|
|
72
|
+
src: string
|
|
73
|
+
poster?: string
|
|
74
|
+
/** `[start, end]` in seconds; the whole video (or the first `maxClip`) when unset. */
|
|
75
|
+
range?: TimeRange | null
|
|
76
|
+
/** The longest clip allowed, in seconds (a chat's upload limit). */
|
|
77
|
+
maxClip?: number
|
|
78
|
+
/** The shortest clip allowed, in seconds. */
|
|
79
|
+
minClip?: number
|
|
80
|
+
/** Lets a rectangle be drawn on the picture (`v-model:region`), for a part to blur. */
|
|
81
|
+
regionSelect?: boolean
|
|
82
|
+
/** The marked rectangle, fractions of the frame (0..1); null when none. */
|
|
83
|
+
region?: FrameRegion | null
|
|
84
|
+
/** Frames per second, for the size of a frame step. */
|
|
85
|
+
frameRate?: number
|
|
86
|
+
/** More items for the right-click menu, given the time it was opened at (under the pointer on the timeline, the playhead elsewhere). */
|
|
87
|
+
menuItems?: (at: number) => ContextMenuItemDef[]
|
|
88
|
+
disabled?: boolean
|
|
89
|
+
labels?: Partial<Record<
|
|
90
|
+
| "play" | "pause" | "stepBack" | "stepForward" | "mute" | "unmute" | "zoomIn" | "zoomOut" | "zoomFit"
|
|
91
|
+
| "start" | "end" | "position" | "length" | "left" | "overview" | "timeline"
|
|
92
|
+
| "region" | "regionHint" | "regionClear" | "failed" | "unsupported" | "invalidTime"
|
|
93
|
+
| "menuAt" | "setStart" | "setEnd" | "goStart" | "goEnd" | "zoomHere" | "volume"
|
|
94
|
+
| "copyTime" | "copyFrame" | "copiedTime" | "copiedFrame" | "copyFailed",
|
|
95
|
+
string
|
|
96
|
+
>>
|
|
97
|
+
}>(),
|
|
98
|
+
{ poster: undefined, range: undefined, maxClip: undefined, minClip: 0.1, region: null, frameRate: 30, labels: () => ({}) },
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const emit = defineEmits<{
|
|
102
|
+
(e: "update:range", value: TimeRange): void
|
|
103
|
+
/** The range after a finished move: a handle let go, a key, a typed time. */
|
|
104
|
+
(e: "change", value: TimeRange): void
|
|
105
|
+
(e: "update:region", value: FrameRegion | null): void
|
|
106
|
+
(e: "error", error: { cause: VideoTrimErrorCause; message: string }): void
|
|
107
|
+
}>()
|
|
108
|
+
|
|
109
|
+
const text = useLabels(
|
|
110
|
+
"videoTrim",
|
|
111
|
+
{
|
|
112
|
+
play: "Play",
|
|
113
|
+
pause: "Pause",
|
|
114
|
+
stepBack: "Previous frame",
|
|
115
|
+
stepForward: "Next frame",
|
|
116
|
+
mute: "Mute",
|
|
117
|
+
unmute: "Unmute",
|
|
118
|
+
zoomIn: "Zoom in",
|
|
119
|
+
zoomOut: "Zoom out",
|
|
120
|
+
zoomFit: "Show whole video",
|
|
121
|
+
start: "Start",
|
|
122
|
+
end: "End",
|
|
123
|
+
position: "Position",
|
|
124
|
+
length: "Length",
|
|
125
|
+
/** `{left}` and `{max}` are replaced by times. */
|
|
126
|
+
left: "{left} left of {max}",
|
|
127
|
+
overview: "Visible part of the timeline",
|
|
128
|
+
timeline: "Timeline — Space plays, arrows step, I and O set start and end",
|
|
129
|
+
region: "Marked area — arrows move it, Shift+arrows resize, Delete removes",
|
|
130
|
+
regionHint: "Drag on the picture to mark an area",
|
|
131
|
+
regionClear: "Remove area",
|
|
132
|
+
failed: "This video couldn't be loaded.",
|
|
133
|
+
unsupported: "This browser can't play this video's format.",
|
|
134
|
+
invalidTime: "Type a time like 1:05.5",
|
|
135
|
+
/** `{time}` is replaced by the time the menu was opened at. */
|
|
136
|
+
menuAt: "At {time}",
|
|
137
|
+
setStart: "Start here",
|
|
138
|
+
setEnd: "End here",
|
|
139
|
+
goStart: "Go to the clip's start",
|
|
140
|
+
goEnd: "Go to the clip's end",
|
|
141
|
+
zoomHere: "Zoom in here",
|
|
142
|
+
volume: "Volume",
|
|
143
|
+
copyTime: "Copy time",
|
|
144
|
+
copyFrame: "Copy frame",
|
|
145
|
+
copiedTime: "Time copied",
|
|
146
|
+
copiedFrame: "Frame copied",
|
|
147
|
+
copyFailed: "Couldn't copy",
|
|
148
|
+
},
|
|
149
|
+
() => props.labels,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const { class: rootClass, attrs: rest } = useMergedAttrs("flex w-full min-w-0 flex-col gap-3 select-none")
|
|
153
|
+
const reduced = usePrefersReducedMotion()
|
|
154
|
+
|
|
155
|
+
const videoEl = ref<HTMLVideoElement | null>(null)
|
|
156
|
+
const samplerEl = ref<HTMLVideoElement | null>(null)
|
|
157
|
+
const stageEl = ref<HTMLElement | null>(null)
|
|
158
|
+
const trackEl = ref<HTMLElement | null>(null)
|
|
159
|
+
|
|
160
|
+
const media = useVideoMedia(videoEl, {
|
|
161
|
+
src: () => props.src,
|
|
162
|
+
onFrame: followPlayback,
|
|
163
|
+
onError: (cause) => emit("error", { cause, message: cause === "unsupported" ? text.value.unsupported : text.value.failed }),
|
|
164
|
+
})
|
|
165
|
+
const { duration, failure, time, playing, wantsPlay, muted, size: videoSize, seek } = media
|
|
166
|
+
/* Every pause the editor makes itself is for a seek, a step or a drag, where the frame must be exact: no easing. */
|
|
167
|
+
const pause = () => media.pause({ instant: true })
|
|
168
|
+
const stageSize = ref({ width: 0, height: 0 })
|
|
169
|
+
const trackWidth = ref(0)
|
|
170
|
+
const ready = computed(() => duration.value > 0 && !failure.value)
|
|
171
|
+
const off = computed(() => props.disabled || !ready.value)
|
|
172
|
+
|
|
173
|
+
const limits = computed(() => ({
|
|
174
|
+
duration: duration.value,
|
|
175
|
+
minClip: Math.max(0, props.minClip ?? 0),
|
|
176
|
+
maxClip: props.maxClip && props.maxClip > 0 ? props.maxClip : Infinity,
|
|
177
|
+
}))
|
|
178
|
+
|
|
179
|
+
/* ---------- range ---------- */
|
|
180
|
+
|
|
181
|
+
const local = ref<TimeRange>([0, 0])
|
|
182
|
+
/** Which handle a pointer holds, so the prop echoing back mid-drag does not fight it. */
|
|
183
|
+
const dragging = ref<0 | 1 | null>(null)
|
|
184
|
+
|
|
185
|
+
const same = (a: TimeRange | null | undefined, b: TimeRange) => !!a && a[0] === b[0] && a[1] === b[1]
|
|
186
|
+
|
|
187
|
+
watch(
|
|
188
|
+
[() => props.range, limits],
|
|
189
|
+
() => {
|
|
190
|
+
if (dragging.value !== null || !duration.value) return
|
|
191
|
+
const next = normalizeRange(props.range, limits.value)
|
|
192
|
+
local.value = next
|
|
193
|
+
// An unset or impossible range is replaced by the one the editor shows, so the model never disagrees with it.
|
|
194
|
+
if (!same(props.range, next)) emit("update:range", next)
|
|
195
|
+
},
|
|
196
|
+
{ deep: true },
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
function put(next: TimeRange, commit: boolean) {
|
|
200
|
+
const changed = !same(local.value, next)
|
|
201
|
+
local.value = next
|
|
202
|
+
if (changed) emit("update:range", next)
|
|
203
|
+
if (commit) emit("change", next)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const clipLength = computed(() => local.value[1] - local.value[0])
|
|
207
|
+
|
|
208
|
+
/* ---------- playback ---------- */
|
|
209
|
+
|
|
210
|
+
/** Playback that began inside the range ends at its end, so Play previews the clip. */
|
|
211
|
+
let stopAtEnd = false
|
|
212
|
+
function followPlayback() {
|
|
213
|
+
if (stopAtEnd && time.value >= local.value[1]) {
|
|
214
|
+
pause()
|
|
215
|
+
seek(local.value[1])
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value, 0.05)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* A press that plays or pauses is answered in the middle of the picture too (VideoFlash). */
|
|
222
|
+
const flash = ref<{ icon: string; n: number } | null>(null)
|
|
223
|
+
function togglePlay() {
|
|
224
|
+
if (!videoEl.value || off.value) return
|
|
225
|
+
flash.value = { icon: wantsPlay.value ? "lucide:pause" : "lucide:play", n: (flash.value?.n ?? 0) + 1 }
|
|
226
|
+
if (wantsPlay.value) {
|
|
227
|
+
media.pause()
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
const [a, b] = local.value
|
|
231
|
+
let from = time.value
|
|
232
|
+
if (from >= b - 1e-3 && from <= b + 1e-3) from = a
|
|
233
|
+
stopAtEnd = from >= a - 1e-3 && from < b
|
|
234
|
+
if (from !== time.value) seek(from)
|
|
235
|
+
media.play()
|
|
236
|
+
}
|
|
237
|
+
function step(seconds: number) {
|
|
238
|
+
if (off.value) return
|
|
239
|
+
pause()
|
|
240
|
+
seek(time.value + seconds)
|
|
241
|
+
if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value)
|
|
242
|
+
}
|
|
243
|
+
function toggleMute() {
|
|
244
|
+
media.setMuted(!muted.value)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const frameStep = computed(() => 1 / Math.max(1, props.frameRate))
|
|
248
|
+
|
|
249
|
+
/* The window the component renders in: an LpFrame's own, inside one. */
|
|
250
|
+
const frameWindow = () => nodeWindow(trackEl.value ?? videoEl.value!)
|
|
251
|
+
|
|
252
|
+
/*
|
|
253
|
+
* Positions on the timeline glide to a value set from elsewhere and follow at
|
|
254
|
+
* once while something moves them continuously: the same split as the kit's
|
|
255
|
+
* sliders (SLIDER_GLIDE), over `left` and `width` here.
|
|
256
|
+
*/
|
|
257
|
+
const TRACK_GLIDE =
|
|
258
|
+
"[&_[data-glide]]:transition-[left,width] [&_[data-glide]]:duration-(--duration-medium) [&_[data-glide]]:ease-(--ease-emphasized) data-[live]:[&_[data-glide]]:transition-none motion-reduce:[&_[data-glide]]:transition-none"
|
|
259
|
+
/* The first placement, once the length is known, is not a change: it lands where it belongs instead of travelling there from zero. */
|
|
260
|
+
const placed = ref(false)
|
|
261
|
+
watch(ready, (ok) => {
|
|
262
|
+
placed.value = false
|
|
263
|
+
if (ok) frameWindow().requestAnimationFrame(() => frameWindow().requestAnimationFrame(() => (placed.value = true)))
|
|
264
|
+
})
|
|
265
|
+
const live = computed(() => !placed.value || dragging.value !== null || scrubbing.value || pinching.value || wheeling.value || playing.value)
|
|
266
|
+
|
|
267
|
+
/* ---------- timeline view ---------- */
|
|
268
|
+
|
|
269
|
+
/* The narrowest view: a couple of seconds across still leaves frames a few pixels apart. */
|
|
270
|
+
const MIN_SPAN = 2
|
|
271
|
+
const view = ref<TimeView>({ start: 0, end: 0 })
|
|
272
|
+
const span = computed(() => Math.max(1e-6, view.value.end - view.value.start))
|
|
273
|
+
const zoomed = computed(() => duration.value > 0 && span.value < duration.value - 1e-6)
|
|
274
|
+
const canZoomIn = computed(() => span.value > Math.min(MIN_SPAN, duration.value) + 1e-6)
|
|
275
|
+
|
|
276
|
+
watch(duration, (d) => {
|
|
277
|
+
view.value = { start: 0, end: d }
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const xOf = (t: number) => ((t - view.value.start) / span.value) * trackWidth.value
|
|
281
|
+
const pct = (t: number) => `${((t - view.value.start) / span.value) * 100}%`
|
|
282
|
+
function timeAt(clientX: number): number {
|
|
283
|
+
const box = trackEl.value?.getBoundingClientRect()
|
|
284
|
+
if (!box || !box.width) return 0
|
|
285
|
+
return view.value.start + ((clientX - box.left) / box.width) * span.value
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Zoom around the playhead while it is in sight, else around the middle: a zoom must never carry the view off to somewhere else. */
|
|
289
|
+
function zoomAnchor(): number {
|
|
290
|
+
const { start, end } = view.value
|
|
291
|
+
return time.value >= start && time.value <= end ? time.value : (start + end) / 2
|
|
292
|
+
}
|
|
293
|
+
function zoomBy(factor: number, anchor = zoomAnchor()) {
|
|
294
|
+
if (!duration.value) return
|
|
295
|
+
view.value = zoomView(view.value, anchor, factor, duration.value, MIN_SPAN)
|
|
296
|
+
}
|
|
297
|
+
function fit() {
|
|
298
|
+
view.value = { start: 0, end: duration.value }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* A wheel or touchpad sends a stream of small steps: each follows at once, as a drag does, rather than easing after the last. */
|
|
302
|
+
const wheeling = ref(false)
|
|
303
|
+
let wheelTimer: ReturnType<typeof setTimeout> | undefined
|
|
304
|
+
function markWheel() {
|
|
305
|
+
wheeling.value = true
|
|
306
|
+
clearTimeout(wheelTimer)
|
|
307
|
+
wheelTimer = setTimeout(() => (wheeling.value = false), 160)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function onWheel(event: WheelEvent) {
|
|
311
|
+
if (!ready.value) return
|
|
312
|
+
const dx = event.shiftKey ? event.deltaY : event.deltaX
|
|
313
|
+
// A sideways scroll (or Shift+wheel) pans; an upright one, and a pinch on a touchpad, zooms.
|
|
314
|
+
if (Math.abs(dx) > Math.abs(event.deltaY) || event.shiftKey) {
|
|
315
|
+
if (!zoomed.value) return
|
|
316
|
+
event.preventDefault()
|
|
317
|
+
markWheel()
|
|
318
|
+
view.value = shiftView(view.value, (dx / Math.max(1, trackWidth.value)) * span.value, duration.value)
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
event.preventDefault()
|
|
322
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1
|
|
323
|
+
const delta = event.deltaY * unit
|
|
324
|
+
/*
|
|
325
|
+
* A mouse wheel moves in notches (a line at a time, or a hundred pixels or
|
|
326
|
+
* so): each notch glides to its zoom like a button press. A touchpad or a
|
|
327
|
+
* pinch sends a stream of small steps that the view follows at once.
|
|
328
|
+
*/
|
|
329
|
+
const notch = event.deltaMode !== 0 || (!event.ctrlKey && Math.abs(delta) >= 50)
|
|
330
|
+
if (!notch) markWheel()
|
|
331
|
+
zoomBy(Math.exp(-delta / (event.ctrlKey ? 100 : 300)), timeAt(event.clientX))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const marks = computed(() => (ready.value ? ticks(view.value, tickStep(span.value, trackWidth.value)) : []))
|
|
335
|
+
const markDigits = computed(() => tickStep(span.value, trackWidth.value) < 1)
|
|
336
|
+
|
|
337
|
+
/* ---------- timeline pointers ---------- */
|
|
338
|
+
|
|
339
|
+
const pointers = new Map<number, number>()
|
|
340
|
+
let pinch: { distance: number; anchor: number } | null = null
|
|
341
|
+
const pinching = ref(false)
|
|
342
|
+
let edgeFrame = 0
|
|
343
|
+
let lastX = 0
|
|
344
|
+
const scrubbing = ref(false)
|
|
345
|
+
|
|
346
|
+
function onHandleDown(end: 0 | 1, event: PointerEvent) {
|
|
347
|
+
if (off.value || event.button !== 0) return
|
|
348
|
+
event.stopPropagation()
|
|
349
|
+
;(event.currentTarget as HTMLElement).focus({ preventScroll: true })
|
|
350
|
+
pause()
|
|
351
|
+
dragging.value = end
|
|
352
|
+
lastX = event.clientX
|
|
353
|
+
startPointerDrag(event, {
|
|
354
|
+
cursor: "ew-resize",
|
|
355
|
+
move: (e) => {
|
|
356
|
+
lastX = e.clientX
|
|
357
|
+
moveHandle()
|
|
358
|
+
edgeScroll()
|
|
359
|
+
},
|
|
360
|
+
end: () => {
|
|
361
|
+
dragging.value = null
|
|
362
|
+
if (edgeFrame) frameWindow().cancelAnimationFrame(edgeFrame)
|
|
363
|
+
edgeFrame = 0
|
|
364
|
+
emit("change", local.value)
|
|
365
|
+
},
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
function moveHandle() {
|
|
369
|
+
const end = dragging.value
|
|
370
|
+
if (end === null) return
|
|
371
|
+
const next = dragEnd(local.value, end, timeAt(lastX), limits.value)
|
|
372
|
+
put(next, false)
|
|
373
|
+
// The picture shows the frame being cut at.
|
|
374
|
+
seek(next[end])
|
|
375
|
+
}
|
|
376
|
+
/* Held past either edge of a zoomed timeline, the view travels, faster the further out. */
|
|
377
|
+
function edgeScroll() {
|
|
378
|
+
if (edgeFrame || !zoomed.value) return
|
|
379
|
+
const box = trackEl.value?.getBoundingClientRect()
|
|
380
|
+
if (!box) return
|
|
381
|
+
const over = lastX < box.left ? lastX - box.left : lastX > box.right ? lastX - box.right : 0
|
|
382
|
+
if (!over || (dragging.value === null && !scrubbing.value)) return
|
|
383
|
+
edgeFrame = frameWindow().requestAnimationFrame(() => {
|
|
384
|
+
edgeFrame = 0
|
|
385
|
+
view.value = shiftView(view.value, (Math.max(-60, Math.min(60, over)) / 6 / Math.max(1, box.width)) * span.value, duration.value)
|
|
386
|
+
if (dragging.value !== null) moveHandle()
|
|
387
|
+
else seek(timeAt(lastX))
|
|
388
|
+
edgeScroll()
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function onTrackDown(event: PointerEvent) {
|
|
393
|
+
if (off.value || (event.pointerType === "mouse" && event.button !== 0)) return
|
|
394
|
+
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
|
395
|
+
pointers.set(event.pointerId, event.clientX)
|
|
396
|
+
if (pointers.size === 2) {
|
|
397
|
+
scrubbing.value = false
|
|
398
|
+
const [a, b] = [...pointers.values()] as [number, number]
|
|
399
|
+
pinch = { distance: Math.max(8, Math.abs(a - b)), anchor: timeAt((a + b) / 2) }
|
|
400
|
+
pinching.value = true
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
if (pointers.size > 2) return
|
|
404
|
+
event.preventDefault()
|
|
405
|
+
trackEl.value?.querySelector<HTMLElement>("[data-lp-playhead]")?.focus({ preventScroll: true })
|
|
406
|
+
pause()
|
|
407
|
+
scrubbing.value = true
|
|
408
|
+
lastX = event.clientX
|
|
409
|
+
seek(timeAt(event.clientX))
|
|
410
|
+
}
|
|
411
|
+
function onTrackMove(event: PointerEvent) {
|
|
412
|
+
if (!pointers.has(event.pointerId)) return
|
|
413
|
+
pointers.set(event.pointerId, event.clientX)
|
|
414
|
+
if (pinch && pointers.size === 2) {
|
|
415
|
+
const [a, b] = [...pointers.values()] as [number, number]
|
|
416
|
+
const distance = Math.max(8, Math.abs(a - b))
|
|
417
|
+
zoomBy(distance / pinch.distance, pinch.anchor)
|
|
418
|
+
pinch.distance = distance
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
if (!scrubbing.value) return
|
|
422
|
+
lastX = event.clientX
|
|
423
|
+
seek(timeAt(event.clientX))
|
|
424
|
+
edgeScroll()
|
|
425
|
+
}
|
|
426
|
+
function onTrackUp(event: PointerEvent) {
|
|
427
|
+
pointers.delete(event.pointerId)
|
|
428
|
+
if (pointers.size < 2) {
|
|
429
|
+
pinch = null
|
|
430
|
+
pinching.value = false
|
|
431
|
+
}
|
|
432
|
+
if (!pointers.size) scrubbing.value = false
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/* The overview: press or drag to centre the view there. */
|
|
436
|
+
function onOverviewDown(event: PointerEvent) {
|
|
437
|
+
if (off.value || event.button !== 0) return
|
|
438
|
+
const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
|
439
|
+
const centreAt = (clientX: number) => {
|
|
440
|
+
const centre = ((clientX - box.left) / Math.max(1, box.width)) * duration.value
|
|
441
|
+
view.value = shiftView(view.value, centre - (view.value.start + view.value.end) / 2, duration.value)
|
|
442
|
+
}
|
|
443
|
+
centreAt(event.clientX)
|
|
444
|
+
startPointerDrag(event, { move: (e) => centreAt(e.clientX), end: () => {} })
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/* ---------- stills ---------- */
|
|
448
|
+
|
|
449
|
+
const STILL_HEIGHT = 56
|
|
450
|
+
const stillWidth = computed(() => {
|
|
451
|
+
const { width, height } = videoSize.value
|
|
452
|
+
return Math.round(Math.min(160, Math.max(40, width && height ? (STILL_HEIGHT * width) / height : STILL_HEIGHT * (16 / 9))))
|
|
453
|
+
})
|
|
454
|
+
const stillSeconds = computed(() => stillSpan(span.value / Math.max(1, trackWidth.value), stillWidth.value))
|
|
455
|
+
const stills = computed(() => {
|
|
456
|
+
if (!ready.value || !trackWidth.value) return []
|
|
457
|
+
const step = stillSeconds.value
|
|
458
|
+
const first = Math.floor(view.value.start / step)
|
|
459
|
+
const last = Math.min(Math.ceil(view.value.end / step), Math.ceil(duration.value / step))
|
|
460
|
+
const out: { key: string; time: number; left: string; width: string }[] = []
|
|
461
|
+
for (let k = first; k < last && out.length < 200; k++) {
|
|
462
|
+
out.push({ key: `${step}:${k}`, time: Math.min(duration.value, k * step + step / 2), left: pct(k * step), width: `${(step / span.value) * 100}%` })
|
|
463
|
+
}
|
|
464
|
+
return out
|
|
465
|
+
})
|
|
466
|
+
const frames = shallowRef(new Map<string, HTMLCanvasElement>())
|
|
467
|
+
let sampler: FrameSampler | null = null
|
|
468
|
+
let stillTimer: ReturnType<typeof setTimeout> | undefined
|
|
469
|
+
|
|
470
|
+
watch(stills, (list) => {
|
|
471
|
+
clearTimeout(stillTimer)
|
|
472
|
+
// 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.
|
|
473
|
+
stillTimer = setTimeout(() => {
|
|
474
|
+
if (!sampler) return
|
|
475
|
+
const centre = (view.value.start + view.value.end) / 2
|
|
476
|
+
sampler.want([...list].sort((a, b) => Math.abs(a.time - centre) - Math.abs(b.time - centre)))
|
|
477
|
+
}, 120)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
/*
|
|
481
|
+
* A still in its tile. Until its own frame is taken, the tile shows a copy of
|
|
482
|
+
* the nearest one already taken (from any zoom), so zooming never empties the
|
|
483
|
+
* strip; the real frame then fades in over that stand-in.
|
|
484
|
+
*/
|
|
485
|
+
function mountFrame(still: { key: string; time: number }, el: Element | null) {
|
|
486
|
+
if (!isHTMLElement(el)) return
|
|
487
|
+
const canvas = frames.value.get(still.key) ?? sampler?.cached(still.key)
|
|
488
|
+
if (canvas) {
|
|
489
|
+
if (canvas.parentElement === el) return
|
|
490
|
+
const standIns = [...el.children]
|
|
491
|
+
el.append(canvas)
|
|
492
|
+
if (!standIns.length || reduced.value) standIns.forEach((c) => c.remove())
|
|
493
|
+
else canvas.addEventListener("animationend", () => standIns.forEach((c) => c.remove()), { once: true })
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
if (el.children.length) return
|
|
497
|
+
const near = nearestFrame(still.time)
|
|
498
|
+
if (!near) return
|
|
499
|
+
const copy = el.ownerDocument.createElement("canvas")
|
|
500
|
+
copy.width = near.width
|
|
501
|
+
copy.height = near.height
|
|
502
|
+
copy.getContext("2d")?.drawImage(near, 0, 0)
|
|
503
|
+
copy.dataset.lpStandIn = ""
|
|
504
|
+
el.append(copy)
|
|
505
|
+
}
|
|
506
|
+
const frameTimes = new Map<string, number>()
|
|
507
|
+
function nearestFrame(t: number): HTMLCanvasElement | undefined {
|
|
508
|
+
let best: HTMLCanvasElement | undefined
|
|
509
|
+
let gap = Infinity
|
|
510
|
+
for (const [key, canvas] of frames.value) {
|
|
511
|
+
const d = Math.abs((frameTimes.get(key) ?? Infinity) - t)
|
|
512
|
+
if (d < gap) {
|
|
513
|
+
gap = d
|
|
514
|
+
best = canvas
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return best
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/* ---------- region ---------- */
|
|
521
|
+
|
|
522
|
+
const picture = computed(() => containRect(stageSize.value.width, stageSize.value.height, videoSize.value.width, videoSize.value.height))
|
|
523
|
+
const draft = ref<FrameRegion | null>(null)
|
|
524
|
+
const shownRegion = computed(() => draft.value ?? props.region ?? null)
|
|
525
|
+
|
|
526
|
+
function fractionAt(event: PointerEvent) {
|
|
527
|
+
const box = stageEl.value?.getBoundingClientRect()
|
|
528
|
+
const p = picture.value
|
|
529
|
+
if (!box || !p.width || !p.height) return { x: 0, y: 0 }
|
|
530
|
+
return { x: (event.clientX - box.left - p.x) / p.width, y: (event.clientY - box.top - p.y) / p.height }
|
|
531
|
+
}
|
|
532
|
+
function onRegionDown(handle: RegionHandle | "draw", event: PointerEvent) {
|
|
533
|
+
if (props.disabled || !props.regionSelect || (event.pointerType === "mouse" && event.button !== 0)) return
|
|
534
|
+
event.stopPropagation()
|
|
535
|
+
pause()
|
|
536
|
+
const start = fractionAt(event)
|
|
537
|
+
const from = props.region ?? null
|
|
538
|
+
startPointerDrag(event, {
|
|
539
|
+
move: (e) => {
|
|
540
|
+
const at = fractionAt(e)
|
|
541
|
+
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)
|
|
542
|
+
},
|
|
543
|
+
end: (completed) => {
|
|
544
|
+
const next = draft.value
|
|
545
|
+
draft.value = null
|
|
546
|
+
// A press without a drag is not a region: a stray click must not leave a sliver to blur.
|
|
547
|
+
if (!completed || !next || (handle === "draw" && (next.w < 0.01 || next.h < 0.01))) return
|
|
548
|
+
emit("update:region", next)
|
|
549
|
+
},
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
function onRegionKey(event: KeyboardEvent) {
|
|
553
|
+
const r = props.region
|
|
554
|
+
if (!r || props.disabled) return
|
|
555
|
+
if (event.key === "Delete" || event.key === "Backspace") {
|
|
556
|
+
event.preventDefault()
|
|
557
|
+
emit("update:region", null)
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
const d = event.altKey ? 0.002 : 0.01
|
|
561
|
+
const moves: Record<string, [number, number]> = { ArrowLeft: [-d, 0], ArrowRight: [d, 0], ArrowUp: [0, -d], ArrowDown: [0, d] }
|
|
562
|
+
const move = moves[event.key]
|
|
563
|
+
if (!move) return
|
|
564
|
+
event.preventDefault()
|
|
565
|
+
event.stopPropagation()
|
|
566
|
+
emit("update:region", adjustRegion(r, event.shiftKey ? "se" : "move", move[0], move[1]))
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/* ---------- keys ---------- */
|
|
570
|
+
|
|
571
|
+
function onKeydown(event: KeyboardEvent) {
|
|
572
|
+
const target = event.target as HTMLElement
|
|
573
|
+
if (event.defaultPrevented || event.ctrlKey || event.metaKey || target.closest("input, textarea, select, [contenteditable='true']")) return
|
|
574
|
+
const handle = target.dataset.lpHandle
|
|
575
|
+
const end = handle === "0" ? 0 : handle === "1" ? 1 : null
|
|
576
|
+
// Another slider inside (the volume) owns its arrows.
|
|
577
|
+
if (target.getAttribute("role") === "slider" && end === null && target.dataset.lpPlayhead === undefined) return
|
|
578
|
+
if ((event.key === "m" || event.key === "M") && !event.altKey) {
|
|
579
|
+
if (!ready.value) return
|
|
580
|
+
event.preventDefault()
|
|
581
|
+
toggleMute()
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
const big = event.shiftKey ? 1 : frameStep.value
|
|
585
|
+
const delta: Record<string, number> = { ArrowLeft: -big, ArrowRight: big, ArrowDown: -big, ArrowUp: big, PageDown: -10, PageUp: 10 }
|
|
586
|
+
if (event.key === " " && target.tagName !== "BUTTON") {
|
|
587
|
+
event.preventDefault()
|
|
588
|
+
togglePlay()
|
|
589
|
+
} else if (event.key in delta || event.key === "Home" || event.key === "End") {
|
|
590
|
+
if (off.value) return
|
|
591
|
+
event.preventDefault()
|
|
592
|
+
if (end !== null) {
|
|
593
|
+
const from = local.value[end]
|
|
594
|
+
const to = event.key === "Home" ? 0 : event.key === "End" ? duration.value : from + delta[event.key]!
|
|
595
|
+
const next = dragEnd(local.value, end, to, limits.value)
|
|
596
|
+
put(next, true)
|
|
597
|
+
pause()
|
|
598
|
+
seek(next[end])
|
|
599
|
+
} else if (event.key === "Home" || event.key === "End") {
|
|
600
|
+
pause()
|
|
601
|
+
seek(event.key === "Home" ? 0 : duration.value)
|
|
602
|
+
} else step(delta[event.key]!)
|
|
603
|
+
if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value)
|
|
604
|
+
} else if ((event.key === "i" || event.key === "I" || event.key === "o" || event.key === "O") && !event.altKey) {
|
|
605
|
+
if (off.value) return
|
|
606
|
+
event.preventDefault()
|
|
607
|
+
put(setEnd(local.value, event.key.toLowerCase() === "i" ? 0 : 1, time.value, limits.value), true)
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/* ---------- typed times ---------- */
|
|
612
|
+
|
|
613
|
+
const drafts = ref<[string | null, string | null]>([null, null])
|
|
614
|
+
const invalid = ref<[boolean, boolean]>([false, false])
|
|
615
|
+
const fieldText = (end: 0 | 1) => drafts.value[end] ?? formatClipTime(local.value[end], "fixed")
|
|
616
|
+
function onFieldInput(end: 0 | 1, value: string) {
|
|
617
|
+
drafts.value[end] = value
|
|
618
|
+
invalid.value[end] = false
|
|
619
|
+
}
|
|
620
|
+
function commitField(end: 0 | 1) {
|
|
621
|
+
const typed = drafts.value[end]
|
|
622
|
+
if (typed === null) return
|
|
623
|
+
const t = parseClipTime(typed)
|
|
624
|
+
if (t === null) {
|
|
625
|
+
invalid.value[end] = true
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
drafts.value[end] = null
|
|
629
|
+
put(setEnd(local.value, end, t, limits.value), true)
|
|
630
|
+
seek(local.value[end])
|
|
631
|
+
if (zoomed.value) view.value = revealTime(view.value, local.value[end], duration.value)
|
|
632
|
+
}
|
|
633
|
+
function revertField(end: 0 | 1) {
|
|
634
|
+
drafts.value[end] = null
|
|
635
|
+
invalid.value[end] = false
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/* ---------- mount ---------- */
|
|
639
|
+
|
|
640
|
+
useResizeObserver(trackEl, (_, el) => (trackWidth.value = (el as HTMLElement).clientWidth), { immediate: true })
|
|
641
|
+
useResizeObserver(stageEl, (_, el) => (stageSize.value = { width: (el as HTMLElement).clientWidth, height: (el as HTMLElement).clientHeight }), { immediate: true })
|
|
642
|
+
|
|
643
|
+
function startSampler() {
|
|
644
|
+
sampler?.dispose()
|
|
645
|
+
sampler = samplerEl.value
|
|
646
|
+
? new FrameSampler(samplerEl.value, () => ({ width: stillWidth.value, height: STILL_HEIGHT }), (key, canvas) => {
|
|
647
|
+
const still = stills.value.find((s) => s.key === key)
|
|
648
|
+
if (still) frameTimes.set(key, still.time)
|
|
649
|
+
const next = new Map(frames.value)
|
|
650
|
+
next.set(key, canvas)
|
|
651
|
+
if (next.size > 240) next.delete(next.keys().next().value as string)
|
|
652
|
+
frames.value = next
|
|
653
|
+
})
|
|
654
|
+
: null
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
watch(
|
|
658
|
+
() => props.src,
|
|
659
|
+
() => {
|
|
660
|
+
frames.value = new Map()
|
|
661
|
+
void nextTick(startSampler)
|
|
662
|
+
},
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
onMounted(startSampler)
|
|
666
|
+
onBeforeUnmount(() => {
|
|
667
|
+
sampler?.dispose()
|
|
668
|
+
clearTimeout(stillTimer)
|
|
669
|
+
if (edgeFrame) frameWindow().cancelAnimationFrame(edgeFrame)
|
|
670
|
+
// The stills' element too: one merely detached keeps its connection open.
|
|
671
|
+
const stillsEl = samplerEl.value
|
|
672
|
+
if (stillsEl) {
|
|
673
|
+
stillsEl.removeAttribute("src")
|
|
674
|
+
stillsEl.load()
|
|
675
|
+
}
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
defineExpose({
|
|
679
|
+
play: () => (videoEl.value?.paused ? togglePlay() : undefined),
|
|
680
|
+
pause,
|
|
681
|
+
seek,
|
|
682
|
+
/** The playhead, in seconds. */
|
|
683
|
+
currentTime: () => time.value,
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
/* ---------- right-click ---------- */
|
|
687
|
+
|
|
688
|
+
const { copy } = useClipboard()
|
|
689
|
+
const { error: toastError } = useToast()
|
|
690
|
+
|
|
691
|
+
/* The picture's own menu: what is done to the video and to the frame on screen. */
|
|
692
|
+
const videoMenu = computed<ContextMenuItemDef[]>(() => {
|
|
693
|
+
if (!ready.value || props.disabled) return []
|
|
694
|
+
const items: ContextMenuItemDef[] = [
|
|
695
|
+
{ label: wantsPlay.value ? text.value.pause : text.value.play, icon: wantsPlay.value ? "lucide:pause" : "lucide:play", shortcut: "Space", onSelect: togglePlay },
|
|
696
|
+
{ label: muted.value ? text.value.unmute : text.value.mute, icon: muted.value ? "lucide:volume-2" : "lucide:volume-x", shortcut: "M", onSelect: toggleMute },
|
|
697
|
+
{ label: text.value.stepBack, icon: "lucide:step-back", shortcut: "ArrowLeft", separatorBefore: true, onSelect: () => step(-frameStep.value) },
|
|
698
|
+
{ label: text.value.stepForward, icon: "lucide:step-forward", shortcut: "ArrowRight", onSelect: () => step(frameStep.value) },
|
|
699
|
+
{ label: text.value.copyTime, icon: "lucide:clock", separatorBefore: true, onSelect: () => void copy(formatClipTime(time.value, "fixed"), { toast: text.value.copiedTime, errorToast: text.value.copyFailed }) },
|
|
700
|
+
{ label: text.value.copyFrame, icon: "lucide:image", onSelect: copyFrame },
|
|
701
|
+
]
|
|
702
|
+
if (props.regionSelect && props.region) items.push({ label: text.value.regionClear, icon: "lucide:x", separatorBefore: true, onSelect: () => emit("update:region", null) })
|
|
703
|
+
return items
|
|
704
|
+
})
|
|
705
|
+
const clockTitle = computed(() => text.value.menuAt.replace("{time}", formatClipTime(time.value, "fixed")))
|
|
706
|
+
|
|
707
|
+
async function copyFrame() {
|
|
708
|
+
const blob = await media.captureFrame()
|
|
709
|
+
if (blob) void copy(blob, { toast: text.value.copiedFrame, errorToast: text.value.copyFailed })
|
|
710
|
+
else toastError(text.value.copyFailed)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Where the timeline's menu was opened: the moment under the pointer. */
|
|
714
|
+
const menuAt = ref(0)
|
|
715
|
+
function onTimeContextMenu(event: MouseEvent) {
|
|
716
|
+
const overview = (event.target as Element).closest?.("[data-lp-overview]")
|
|
717
|
+
if (overview) {
|
|
718
|
+
const box = overview.getBoundingClientRect()
|
|
719
|
+
menuAt.value = ((event.clientX - box.left) / Math.max(1, box.width)) * duration.value
|
|
720
|
+
} else menuAt.value = timeAt(event.clientX)
|
|
721
|
+
menuAt.value = Math.min(duration.value, Math.max(0, menuAt.value))
|
|
722
|
+
}
|
|
723
|
+
/* The timeline's menu: what is done at a moment. */
|
|
724
|
+
const timeMenu = computed<ContextMenuItemDef[]>(() => {
|
|
725
|
+
if (!ready.value || props.disabled) return []
|
|
726
|
+
const at = menuAt.value
|
|
727
|
+
const items: ContextMenuItemDef[] = [
|
|
728
|
+
{
|
|
729
|
+
label: text.value.setStart,
|
|
730
|
+
icon: "lucide:arrow-left-to-line",
|
|
731
|
+
shortcut: "I",
|
|
732
|
+
onSelect: () => {
|
|
733
|
+
put(setEnd(local.value, 0, at, limits.value), true)
|
|
734
|
+
seek(at)
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
label: text.value.setEnd,
|
|
739
|
+
icon: "lucide:arrow-right-to-line",
|
|
740
|
+
shortcut: "O",
|
|
741
|
+
onSelect: () => {
|
|
742
|
+
put(setEnd(local.value, 1, at, limits.value), true)
|
|
743
|
+
seek(at)
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
{ label: text.value.goStart, separatorBefore: true, onSelect: () => seek(local.value[0]) },
|
|
747
|
+
{ label: text.value.goEnd, onSelect: () => seek(local.value[1]) },
|
|
748
|
+
zoomed.value
|
|
749
|
+
? { label: text.value.zoomFit, icon: "lucide:maximize-2", separatorBefore: true, onSelect: fit }
|
|
750
|
+
: { label: text.value.zoomHere, icon: "lucide:zoom-in", separatorBefore: true, disabled: !canZoomIn.value, onSelect: () => zoomBy(4, at) },
|
|
751
|
+
]
|
|
752
|
+
const extra = props.menuItems?.(at) ?? []
|
|
753
|
+
return [...items, ...extra.map((item, i) => (i === 0 ? { ...item, separatorBefore: true } : item))]
|
|
754
|
+
})
|
|
755
|
+
const menuTitle = computed(() => text.value.menuAt.replace("{time}", formatClipTime(menuAt.value, "fixed")))
|
|
756
|
+
|
|
757
|
+
const handleText = (t: number) => formatClipTime(t, "fixed")
|
|
758
|
+
const widest = computed(() => widestClipTime(duration.value))
|
|
759
|
+
const leftOf = (left: string, max: string) => text.value.left.replace("{left}", left).replace("{max}", max)
|
|
760
|
+
const leftText = computed(() =>
|
|
761
|
+
Number.isFinite(limits.value.maxClip)
|
|
762
|
+
? leftOf(formatClipTime(Math.max(0, limits.value.maxClip - clipLength.value), "fixed"), formatClipTime(limits.value.maxClip, "fixed"))
|
|
763
|
+
: "",
|
|
764
|
+
)
|
|
765
|
+
const widestLeft = computed(() => leftOf(widestClipTime(limits.value.maxClip), widestClipTime(limits.value.maxClip)))
|
|
766
|
+
</script>
|
|
767
|
+
|
|
768
|
+
<template>
|
|
769
|
+
<div :class="rootClass" v-bind="rest" :data-disabled="disabled || undefined" @keydown="onKeydown">
|
|
770
|
+
<!--
|
|
771
|
+
The picture. Its own controls are off: the region is drawn on it, and the
|
|
772
|
+
keys are the editor's. A right-click here is about the picture (play, the
|
|
773
|
+
sound, the frame); one on the timeline below is about a moment.
|
|
774
|
+
-->
|
|
775
|
+
<!-- The menu's trigger is a wrapper of its own: reka's as-child puts its ref on the element it wraps, which would take the stage's. -->
|
|
776
|
+
<LpContextMenu :items="videoMenu" :title="clockTitle" always>
|
|
777
|
+
<div>
|
|
778
|
+
<div ref="stageEl" class="relative aspect-video w-full overflow-hidden rounded-card bg-black">
|
|
779
|
+
<video
|
|
780
|
+
ref="videoEl"
|
|
781
|
+
class="absolute inset-0 size-full object-contain"
|
|
782
|
+
:src="src"
|
|
783
|
+
:poster="poster"
|
|
784
|
+
preload="metadata"
|
|
785
|
+
playsinline
|
|
786
|
+
v-on="media.listeners"
|
|
787
|
+
@click="!regionSelect && togglePlay()"
|
|
788
|
+
/>
|
|
789
|
+
<VideoFlash :flash="flash" />
|
|
790
|
+
<!-- Stills come from a second element: seeking this one would move the picture under the person. -->
|
|
791
|
+
<video
|
|
792
|
+
ref="samplerEl"
|
|
793
|
+
class="pointer-events-none absolute size-px opacity-0"
|
|
794
|
+
:src="src"
|
|
795
|
+
preload="metadata"
|
|
796
|
+
muted
|
|
797
|
+
playsinline
|
|
798
|
+
aria-hidden="true"
|
|
799
|
+
tabindex="-1"
|
|
800
|
+
/>
|
|
801
|
+
<Transition v-bind="FADE">
|
|
802
|
+
<div
|
|
803
|
+
v-if="regionSelect && ready"
|
|
804
|
+
class="absolute cursor-crosshair touch-none"
|
|
805
|
+
:style="{ left: `${picture.x}px`, top: `${picture.y}px`, width: `${picture.width}px`, height: `${picture.height}px` }"
|
|
806
|
+
data-lp-region-layer
|
|
807
|
+
@pointerdown="onRegionDown('draw', $event)"
|
|
808
|
+
>
|
|
809
|
+
<!-- Comes in under the pointer that draws it (no growing in: the drag already does that) and leaves with the dimming around it. -->
|
|
810
|
+
<Transition v-bind="FADE_SCALE" :enter-from-class="draft ? 'opacity-0' : FADE_SCALE.enterFromClass">
|
|
811
|
+
<div
|
|
812
|
+
v-if="shownRegion"
|
|
813
|
+
class="absolute cursor-move rounded-inner border-2 border-dashed border-white/90 bg-white/10 shadow-[0_0_0_9999px_rgb(0_0_0/0.35)] outline-none transition-[left,top,width,height] duration-(--duration-medium) ease-(--ease-emphasized) focus-visible:ring-2 focus-visible:ring-ring data-[live]:transition-none motion-reduce:transition-none"
|
|
814
|
+
:data-live="draft ? '' : undefined"
|
|
815
|
+
:style="{ left: `${shownRegion.x * 100}%`, top: `${shownRegion.y * 100}%`, width: `${shownRegion.w * 100}%`, height: `${shownRegion.h * 100}%` }"
|
|
816
|
+
tabindex="0"
|
|
817
|
+
role="group"
|
|
818
|
+
:aria-label="text.region"
|
|
819
|
+
data-lp-region
|
|
820
|
+
@pointerdown="onRegionDown('move', $event)"
|
|
821
|
+
@keydown="onRegionKey"
|
|
822
|
+
>
|
|
823
|
+
<span
|
|
824
|
+
v-for="corner in (['nw', 'ne', 'sw', 'se'] as const)"
|
|
825
|
+
:key="corner"
|
|
826
|
+
class="absolute grid size-6 place-items-center pointer-coarse:size-11"
|
|
827
|
+
:class="[
|
|
828
|
+
corner[0] === 'n' ? 'top-0 -translate-y-1/2' : 'bottom-0 translate-y-1/2',
|
|
829
|
+
corner[1] === 'w' ? 'left-0 -translate-x-1/2' : 'right-0 translate-x-1/2',
|
|
830
|
+
corner === 'nw' || corner === 'se' ? 'cursor-nwse-resize' : 'cursor-nesw-resize',
|
|
831
|
+
]"
|
|
832
|
+
:data-lp-corner="corner"
|
|
833
|
+
@pointerdown="onRegionDown(corner, $event)"
|
|
834
|
+
>
|
|
835
|
+
<span class="size-3 rounded-full bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.3),0_1px_3px_rgb(0_0_0/0.35)]" />
|
|
836
|
+
</span>
|
|
837
|
+
</div>
|
|
838
|
+
</Transition>
|
|
839
|
+
</div>
|
|
840
|
+
</Transition>
|
|
841
|
+
<Transition v-bind="FADE">
|
|
842
|
+
<span
|
|
843
|
+
v-if="regionSelect && ready && !shownRegion && !playing && !disabled"
|
|
844
|
+
class="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-pill bg-black/55 px-3 py-1 text-xs whitespace-nowrap text-white/85"
|
|
845
|
+
>{{ text.regionHint }}</span>
|
|
846
|
+
</Transition>
|
|
847
|
+
<!-- The app's own actions over the picture's top right corner: a bin that takes the video back out of a dropzone, say. -->
|
|
848
|
+
<div v-if="$slots.actions" class="absolute top-2 right-2 flex gap-0.5 rounded-control bg-black/45">
|
|
849
|
+
<slot name="actions" :button-class="OVERLAY_BUTTON" />
|
|
850
|
+
</div>
|
|
851
|
+
<Transition v-bind="FADE">
|
|
852
|
+
<div
|
|
853
|
+
v-if="failure"
|
|
854
|
+
role="alert"
|
|
855
|
+
class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-white/80"
|
|
856
|
+
>
|
|
857
|
+
<LpIcon name="lucide:video-off" :size="32" />
|
|
858
|
+
<p>{{ failure === "unsupported" ? text.unsupported : text.failed }}</p>
|
|
859
|
+
</div>
|
|
860
|
+
</Transition>
|
|
861
|
+
</div>
|
|
862
|
+
</div>
|
|
863
|
+
</LpContextMenu>
|
|
864
|
+
|
|
865
|
+
<!-- Transport, sound and zoom. One line at every width: a row that wraps re-wraps as its numbers change. -->
|
|
866
|
+
<div class="flex items-center gap-0.5">
|
|
867
|
+
<LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="wantsPlay ? text.pause : text.play" :title="wantsPlay ? text.pause : text.play" @click="togglePlay">
|
|
868
|
+
<Transition v-bind="ICON_SWAP">
|
|
869
|
+
<LpIcon :key="wantsPlay ? 'pause' : 'play'" :name="wantsPlay ? 'lucide:pause' : 'lucide:play'" :size="16" />
|
|
870
|
+
</Transition>
|
|
871
|
+
</LpButton>
|
|
872
|
+
<LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="text.stepBack" :title="text.stepBack" @click="step(-frameStep)">
|
|
873
|
+
<LpIcon name="lucide:step-back" :size="16" />
|
|
874
|
+
</LpButton>
|
|
875
|
+
<LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="text.stepForward" :title="text.stepForward" @click="step(frameStep)">
|
|
876
|
+
<LpIcon name="lucide:step-forward" :size="16" />
|
|
877
|
+
</LpButton>
|
|
878
|
+
<span class="min-w-0 truncate px-1.5 font-mono text-xs text-muted" data-lp-clock>
|
|
879
|
+
<SteadyText class="text-ink" :text="formatClipTime(time, 'fixed')" :widest="widest" /> / {{ formatClipTime(duration, "fixed") }}
|
|
880
|
+
</span>
|
|
881
|
+
<VideoVolume :media="media" :labels="text" :disabled="!ready" />
|
|
882
|
+
<span class="flex-1" />
|
|
883
|
+
<Transition v-bind="FADE_SCALE">
|
|
884
|
+
<LpButton v-if="zoomed" size="sm" variant="ghost" square :disabled="off" :aria-label="text.zoomFit" :title="text.zoomFit" @click="fit">
|
|
885
|
+
<LpIcon name="lucide:maximize-2" :size="16" />
|
|
886
|
+
</LpButton>
|
|
887
|
+
</Transition>
|
|
888
|
+
<LpButton size="sm" variant="ghost" square :disabled="off || !zoomed" :aria-label="text.zoomOut" :title="text.zoomOut" @click="zoomBy(0.5)">
|
|
889
|
+
<LpIcon name="lucide:zoom-out" :size="16" />
|
|
890
|
+
</LpButton>
|
|
891
|
+
<LpButton size="sm" variant="ghost" square :disabled="off || !canZoomIn" :aria-label="text.zoomIn" :title="text.zoomIn" @click="zoomBy(2)">
|
|
892
|
+
<LpIcon name="lucide:zoom-in" :size="16" />
|
|
893
|
+
</LpButton>
|
|
894
|
+
</div>
|
|
895
|
+
|
|
896
|
+
<!--
|
|
897
|
+
Ruler, stills, range and playhead. Positions glide when they are set from
|
|
898
|
+
elsewhere (a key, a typed time, a zoom button) and follow the pointer with
|
|
899
|
+
no delay while it moves them, as the kit's sliders do. Marks and stills
|
|
900
|
+
that come into view or leave it fade rather than blink.
|
|
901
|
+
-->
|
|
902
|
+
<LpContextMenu :items="timeMenu" :title="menuTitle" always>
|
|
903
|
+
<div class="relative" :class="TRACK_GLIDE" :data-live="live || undefined" @wheel="onWheel" @contextmenu="onTimeContextMenu">
|
|
904
|
+
<TransitionGroup tag="div" class="relative h-4 overflow-hidden text-[10px] text-muted tabular-nums" aria-hidden="true" v-bind="FADE">
|
|
905
|
+
<span
|
|
906
|
+
v-for="m in marks"
|
|
907
|
+
:key="m"
|
|
908
|
+
class="absolute top-0 whitespace-nowrap"
|
|
909
|
+
:class="xOf(m) < 16 ? '' : xOf(m) > trackWidth - 16 ? '-translate-x-full' : '-translate-x-1/2'"
|
|
910
|
+
:style="{ left: pct(m) }"
|
|
911
|
+
data-glide
|
|
912
|
+
>{{ formatClipTime(m, markDigits) }}</span>
|
|
913
|
+
</TransitionGroup>
|
|
914
|
+
<div
|
|
915
|
+
ref="trackEl"
|
|
916
|
+
class="relative h-14 touch-none rounded-control bg-surface-soft transition-opacity duration-(--duration-fast) motion-reduce:transition-none"
|
|
917
|
+
:class="off ? 'opacity-60' : 'cursor-text'"
|
|
918
|
+
:aria-label="text.timeline"
|
|
919
|
+
role="group"
|
|
920
|
+
@pointerdown="onTrackDown"
|
|
921
|
+
@pointermove="onTrackMove"
|
|
922
|
+
@pointerup="onTrackUp"
|
|
923
|
+
@pointercancel="onTrackUp"
|
|
924
|
+
>
|
|
925
|
+
<div class="absolute inset-0 overflow-hidden rounded-control">
|
|
926
|
+
<TransitionGroup v-bind="FADE">
|
|
927
|
+
<div
|
|
928
|
+
v-for="s in stills"
|
|
929
|
+
:key="s.key"
|
|
930
|
+
:ref="(el) => mountFrame(s, el as Element | null)"
|
|
931
|
+
class="absolute inset-y-0 overflow-hidden [&>canvas]:absolute [&>canvas]:inset-0 [&>canvas]:size-full [&>canvas]:object-cover"
|
|
932
|
+
:class="{ 'lp-trim-still-in': !reduced }"
|
|
933
|
+
:style="{ left: s.left, width: s.width }"
|
|
934
|
+
:data-lp-still="frames.has(s.key) ? 'ready' : 'pending'"
|
|
935
|
+
data-glide
|
|
936
|
+
/>
|
|
937
|
+
</TransitionGroup>
|
|
938
|
+
<!-- What falls outside the clip is dimmed, not hidden: it is still where the clip could move. -->
|
|
939
|
+
<div class="absolute inset-y-0 left-0 bg-black/55" :style="{ width: pct(local[0]) }" data-glide />
|
|
940
|
+
<div class="absolute inset-y-0 right-0 bg-black/55" :style="{ left: pct(local[1]) }" data-glide />
|
|
941
|
+
</div>
|
|
942
|
+
<!-- The range and its grips arrive with the video's length, faded in rather than popped onto the strip. -->
|
|
943
|
+
<div
|
|
944
|
+
class="pointer-events-none absolute inset-0 transition-opacity duration-(--duration-medium) ease-(--ease-emphasized) motion-reduce:transition-none"
|
|
945
|
+
:class="ready ? '' : 'opacity-0'"
|
|
946
|
+
>
|
|
947
|
+
<div
|
|
948
|
+
class="absolute inset-y-0 rounded-control border-y-2 border-white/85 shadow-[0_0_0_1px_rgb(0_0_0/0.25)]"
|
|
949
|
+
:style="{ left: pct(local[0]), width: `${(clipLength / span) * 100}%` }"
|
|
950
|
+
data-lp-range
|
|
951
|
+
data-glide
|
|
952
|
+
/>
|
|
953
|
+
<!-- A grip as on LpChart's overview: narrow and neutral, lit only while it is held, hovered or focused. -->
|
|
954
|
+
<div
|
|
955
|
+
v-for="end in ([0, 1] as const)"
|
|
956
|
+
:key="end"
|
|
957
|
+
class="group/handle absolute inset-y-0 z-10 flex w-6 -translate-x-1/2 touch-none justify-center outline-none pointer-coarse:w-11"
|
|
958
|
+
:class="[off ? '' : 'cursor-ew-resize', ready ? 'pointer-events-auto' : '']"
|
|
959
|
+
:style="{ left: pct(local[end]) }"
|
|
960
|
+
role="slider"
|
|
961
|
+
:tabindex="off ? -1 : 0"
|
|
962
|
+
:aria-label="end === 0 ? text.start : text.end"
|
|
963
|
+
:aria-valuemin="0"
|
|
964
|
+
:aria-valuemax="Math.round(duration * 1000) / 1000"
|
|
965
|
+
:aria-valuenow="local[end]"
|
|
966
|
+
:aria-valuetext="handleText(local[end])"
|
|
967
|
+
:aria-disabled="off || undefined"
|
|
968
|
+
:aria-hidden="ready ? undefined : 'true'"
|
|
969
|
+
:data-lp-handle="end"
|
|
970
|
+
:data-active="dragging === end || undefined"
|
|
971
|
+
data-glide
|
|
972
|
+
@pointerdown="onHandleDown(end, $event)"
|
|
973
|
+
>
|
|
974
|
+
<span
|
|
975
|
+
class="my-1 flex w-2 items-center justify-center rounded-inner bg-white/90 shadow-[0_0_0_1px_rgb(0_0_0/0.3),0_1px_3px_rgb(0_0_0/0.35)] transition-[background-color,scale] duration-(--duration-fast) ease-(--ease-emphasized) group-hover/handle:bg-white group-data-active/handle:scale-x-125 group-data-active/handle:bg-white group-focus-visible/handle:ring-2 group-focus-visible/handle:ring-ring motion-reduce:transition-none motion-reduce:group-data-active/handle:scale-x-100"
|
|
976
|
+
>
|
|
977
|
+
<span class="h-4 w-px rounded-pill bg-black/35" />
|
|
978
|
+
</span>
|
|
979
|
+
</div>
|
|
980
|
+
</div>
|
|
981
|
+
<div
|
|
982
|
+
class="group/playhead absolute -top-4 bottom-0 z-20 w-0 outline-none"
|
|
983
|
+
:style="{ left: pct(time) }"
|
|
984
|
+
role="slider"
|
|
985
|
+
:tabindex="off ? -1 : 0"
|
|
986
|
+
:aria-label="text.position"
|
|
987
|
+
:aria-valuemin="0"
|
|
988
|
+
:aria-valuemax="Math.round(duration * 1000) / 1000"
|
|
989
|
+
:aria-valuenow="Math.round(time * 1000) / 1000"
|
|
990
|
+
:aria-valuetext="handleText(time)"
|
|
991
|
+
data-lp-playhead
|
|
992
|
+
:data-glide="playing ? undefined : ''"
|
|
993
|
+
>
|
|
994
|
+
<!-- Fades out past either edge of a zoomed view instead of vanishing there. -->
|
|
995
|
+
<span
|
|
996
|
+
class="pointer-events-none absolute inset-0 transition-opacity duration-(--duration-fast) motion-reduce:transition-none"
|
|
997
|
+
:class="ready && time >= view.start && time <= view.end ? '' : 'opacity-0'"
|
|
998
|
+
>
|
|
999
|
+
<span class="absolute inset-y-0 -left-px w-0.5 rounded-pill bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.35)]" />
|
|
1000
|
+
<span class="absolute -top-0.5 -left-1.5 size-3 rounded-full bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.3),0_1px_3px_rgb(0_0_0/0.35)] group-focus-visible/playhead:ring-2 group-focus-visible/playhead:ring-ring" />
|
|
1001
|
+
</span>
|
|
1002
|
+
</div>
|
|
1003
|
+
</div>
|
|
1004
|
+
<!-- The whole video while zoomed in, drawn like LpChart's overview: the window it shows, the clip within it. -->
|
|
1005
|
+
<Transition v-bind="FADE">
|
|
1006
|
+
<div
|
|
1007
|
+
v-if="zoomed"
|
|
1008
|
+
class="relative mt-1.5 h-2.5 cursor-pointer touch-none rounded-pill bg-surface-soft"
|
|
1009
|
+
:aria-label="text.overview"
|
|
1010
|
+
role="presentation"
|
|
1011
|
+
data-lp-overview
|
|
1012
|
+
@pointerdown="onOverviewDown"
|
|
1013
|
+
>
|
|
1014
|
+
<span class="absolute inset-y-0.5 rounded-pill bg-line-strong" :style="{ left: `${(local[0] / duration) * 100}%`, width: `${(clipLength / duration) * 100}%` }" data-glide />
|
|
1015
|
+
<span class="absolute inset-y-0 rounded-pill border border-ink/60" :style="{ left: `${(view.start / duration) * 100}%`, width: `${(span / duration) * 100}%` }" data-glide />
|
|
1016
|
+
</div>
|
|
1017
|
+
</Transition>
|
|
1018
|
+
</div>
|
|
1019
|
+
</LpContextMenu>
|
|
1020
|
+
|
|
1021
|
+
<!-- Exact times, typed, and the clip's length. Every value holds its width, so nothing on the line moves as they change. -->
|
|
1022
|
+
<div class="flex flex-wrap items-end gap-x-3 gap-y-2">
|
|
1023
|
+
<LpInput
|
|
1024
|
+
v-for="end in ([0, 1] as const)"
|
|
1025
|
+
:key="end"
|
|
1026
|
+
size="sm"
|
|
1027
|
+
class="w-28"
|
|
1028
|
+
:label="end === 0 ? text.start : text.end"
|
|
1029
|
+
:model-value="fieldText(end)"
|
|
1030
|
+
:disabled="off"
|
|
1031
|
+
:invalid="invalid[end]"
|
|
1032
|
+
:title="invalid[end] ? text.invalidTime : undefined"
|
|
1033
|
+
inputmode="decimal"
|
|
1034
|
+
spellcheck="false"
|
|
1035
|
+
autocomplete="off"
|
|
1036
|
+
:data-lp-time="end"
|
|
1037
|
+
@update:model-value="onFieldInput(end, $event)"
|
|
1038
|
+
@blur="commitField(end)"
|
|
1039
|
+
@keydown.enter.prevent="commitField(end)"
|
|
1040
|
+
@keydown.esc="revertField(end)"
|
|
1041
|
+
/>
|
|
1042
|
+
<div class="flex flex-col gap-0.5 pb-0.5 text-xs">
|
|
1043
|
+
<span class="text-muted">{{ text.length }}</span>
|
|
1044
|
+
<span class="font-mono text-sm text-ink" data-lp-length><SteadyText :text="formatClipTime(clipLength, 'fixed')" :widest="widest" /></span>
|
|
1045
|
+
</div>
|
|
1046
|
+
<Transition v-bind="FADE">
|
|
1047
|
+
<span v-if="leftText" class="pb-1 font-mono text-xs text-muted" data-lp-left><SteadyText :text="leftText" :widest="widestLeft" /></span>
|
|
1048
|
+
</Transition>
|
|
1049
|
+
<span class="flex-1" />
|
|
1050
|
+
<Transition v-bind="FADE_SCALE">
|
|
1051
|
+
<LpButton v-if="regionSelect" size="sm" variant="outline" :disabled="disabled || !region" @click="emit('update:region', null)">
|
|
1052
|
+
<LpIcon name="lucide:x" :size="14" />{{ text.regionClear }}
|
|
1053
|
+
</LpButton>
|
|
1054
|
+
</Transition>
|
|
1055
|
+
</div>
|
|
1056
|
+
</div>
|
|
1057
|
+
</template>
|
|
1058
|
+
|
|
1059
|
+
<style scoped>
|
|
1060
|
+
.lp-trim-still-in > :deep(canvas:not([data-lp-stand-in])) {
|
|
1061
|
+
animation: lp-trim-still var(--duration-medium) var(--ease-emphasized);
|
|
1062
|
+
}
|
|
1063
|
+
@keyframes lp-trim-still {
|
|
1064
|
+
from { opacity: 0; }
|
|
1065
|
+
}
|
|
1066
|
+
</style>
|